SIGTERM, and a scaling model that matches your workload. The last mission box.
Tied to mission: "deploy and operate in production" — and the whole point: confidence running async workloads on Redis without paging yourself at 3am. Synthesizes L1–L6.
This is the single most important prod setting, and it's not in your code — it's in Redis.
maxmemory-policy MUST be noeviction. BullMQ cannot work if Redis evicts keys. A cache-style policy (allkeys-lru etc.) will silently delete jobs and corrupt the queue. This is the only policy that guarantees correct behaviour.
| Redis setting | Recommended | Why |
|---|---|---|
maxmemory-policy | noeviction | Never evict queue data. |
| Persistence | AOF (Append-Only File), ~1s fsync | Many hosts disable persistence by default → a restart loses all pending jobs. AOF is robust + fast. |
| Memory headroom | Monitor used_memory; size for backlog bursts | With noeviction, hitting maxmemory makes writes fail. |
maxRetriesPerRequest: null ruleBullMQ uses blocking Redis commands. ioredis's default (maxRetriesPerRequest) blocks BullMQ from working at all. You must null it:
import IORedis from 'ioredis';
const connection = new IORedis({ maxRetriesPerRequest: null });
| Class | Connections it holds |
|---|---|
Queue | 1 (can share) |
Worker | 1 + 1 duplicate internally (the duplicate runs blocking commands) |
QueueEvents | 1 + 1 duplicate internally (blocking) |
So you can pass one ioredis instance to a Queue + Worker — but the Worker/QueueEvents internally duplicate() it for their blocking connection. The instance must support duplicate() (ioredis does). Redis connections are cheap; don't over-optimize sharing unless your provider caps you.
prefixnew Queue('email', { prefix: 'myapp-prod', connection });
// keys become bull:myapp-prod:... instead of bull:email:...
Lets staging + prod share one Redis without colliding — though separate Redis instances is cleaner.
worker.close() + your own timeoutOn SIGTERM (deploy, k8s rolling update, scale-down), you want in-flight jobs to finish — not become stalled.
let shuttingDown = false;
async function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
console.log('draining worker...');
await worker.close(); // stops picking new jobs; waits for active ones
await queue.close();
await connection.quit();
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
worker.close() does NOT self-timeout. It waits forever for active jobs to finish. But your orchestrator (k8s) sends SIGKILL after a grace period (~30s). If a job runs longer, it's killed mid-flight → stalled. Two fixes: (a) keep jobs short; (b) race worker.close() against a timer and force-exit, accepting that long jobs will stall and be re-queued by another worker (that's the safety net).
| Knob | What it scales | Good for |
|---|---|---|
concurrency: N (Worker option) | Jobs in flight per worker, via the event loop | I/O-bound work (HTTP, DB). Crank it up. |
| Multiple Workers / processes | True parallelism across CPUs / machines | CPU-bound work. Scales ~linearly with workers. |
concurrency on CPU-heavy jobs lowers throughput. It just adds context-switching overhead. For CPU work, keep concurrency low and scale by adding worker processes/instances instead.
If a job blocks the event loop (image resize, crypto, big parses), it starves the heartbeat → stalls (Lesson 6). Move it to a sandboxed processor — runs in a separate process or thread:
// worker.ts — the host
import { Worker } from 'bullmq';
new Worker('images', undefined, {
connection,
useWorkerThreads: true, // v3.13+: threads (lighter) instead of spawn
authorURL: import.meta.url, // points to the processor file
});
// processor.ts — separate file, runs in the sandbox
import { parentPort } from 'worker_threads';
module.exports = async (job) => { /* heavy CPU here, isolated */ };
Crash in the sandbox doesn't kill the worker; the event loop of the host stays free to renew locks.
Retries (L2), rate-limit re-queues (L4), stalled re-runs (L6), and flow resumption (L5) all mean the same job can execute more than once. Design for it:
maxmemory-policy noeviction + AOF persistence in Redis.ioredis instance created with maxRetriesPerRequest: null.removeOnComplete + removeOnFail on every queue (L6).SIGTERM/SIGINT handlers calling worker.close() + connection.quit().concurrency tuned for I/O; extra worker processes for CPU (+ sandboxed if blocking).upsertJobScheduler called at boot — idempotent, safe on every deploy (L3).process.on('uncaughtException') / unhandledRejection handlers — log and exit, don't leave a zombie.import { Queue, Worker, QueueEvents, MetricsTime } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis({ maxRetriesPerRequest: null });
const email = new Queue('email', {
connection,
defaultJobOptions: {
attempts: 5,
backoff: { type: 'exponential', delay: 2000, jitter: 0.5 },
removeOnComplete: { age: 86_400, count: 2000 },
removeOnFail: { age: 604_800, count: 5000 },
},
});
// recurring digest — safe to call every boot
await email.upsertJobScheduler('daily-digest',
{ pattern: '0 0 9 * * 1-5' },
{ name: 'digest', data: {} });
const worker = new Worker('email', async (job) => {
if (await alreadySent(job.id)) return; // idempotent
await sendMail(job.data);
}, {
connection,
concurrency: 20, // I/O-bound → high
stalledInterval: 30_000,
maxStalledCount: 1,
metrics: { maxDataPoints: MetricsTime.ONE_WEEK * 2 },
});
worker.on('stalled', (id) => alert('stalled', id));
let shuttingDown = false;
async function shutdown() {
if (shuttingDown) return; shuttingDown = true;
await worker.close(); await connection.quit(); process.exit(0);
}
process.on('SIGTERM', shutdown).on('SIGINT', shutdown);
noeviction + AOF, or it'll lose/evict jobs.maxRetriesPerRequest: null; blocking cmds are internal duplicates.worker.close() on SIGTERM — but it doesn't self-timeout; race it against your grace period.concurrency is for I/O; scale worker processes for CPU; sandbox blocking work.1. Required Redis maxmemory-policy for BullMQ?
2. Which ioredis option is mandatory?
3. Recommended Redis persistence for BullMQ?
4. What does worker.close() do with active jobs?
5. Does worker.close() self-timeout?
6. Which signal triggers graceful shutdown in k8s?
7. High concurrency is best for what work?
8. How do you scale throughput for CPU-bound jobs?
9. Why must jobs be idempotent in production?
10. What isolates staging from prod on shared Redis?
This is the end of the core curriculum. Ask me anything — or tell me a new direction and I'll plan the next arc.