Draining BullMQ Workers Before Shutdown
This is the Node implementation of the drain model in Graceful Shutdown & Worker Deployments, under Backend Frameworks & Worker Scaling β how BullMQ's lock-based delivery behaves when a process exits, and what to call so it exits cleanly.
Problem Statement
Your Node workers process image transcodes averaging 25 seconds. On every deploy, PM2 sends SIGINT, the process exits immediately, and BullMQ's stalled-job checker eventually notices the lock expired and re-runs those jobs on another worker β thirty seconds later, from the beginning. Users see duplicate uploads in their media library because the transcode's side effect ran twice. You want the process to stop taking new jobs, finish the ones it holds, release its Redis locks, and exit before the orchestrator loses patience.
Prerequisites
- BullMQ 4+ and Redis 6+. The lock semantics described here changed subtly across BullMQ 3 and 4, so pin the version.
- Known p99 job duration β
lockDurationand the grace period are both derived from it. - A process manager that sends a signal and waits: Kubernetes, systemd, or PM2 with
kill_timeoutraised from its 1.6-second default. - Idempotent processors, because a stalled job is re-run from the start rather than resumed.
Step 1 β Understand Locks and Stalled Jobs
BullMQ has no broker-side visibility timeout. A worker holds a lock on the job in Redis, renewed every lockDuration / 2 while the job runs. If the process dies, the lock is not renewed; the stalled-job checker on another worker notices, and moves the job back to wait β where it runs again from the beginning.
The consequence is that draining matters more in BullMQ than in brokers with server-side leases: an abrupt exit does not just delay the job, it repeats work already done.
Step 2 β Call close() and Await It
worker.close() stops the worker from taking new jobs and resolves once active jobs finish. The failure mode is not awaiting it, or awaiting it after the process has already been told to exit.
// worker.js
const { Worker, QueueEvents } = require('bullmq');
const connection = { host: process.env.REDIS_HOST, port: 6379, maxRetriesPerRequest: null };
const worker = new Worker('transcode', processor, {
connection,
concurrency: 4,
lockDuration: 60_000, // > p99 job duration; renewed every 30s
stalledInterval: 30_000, // how often this worker checks for stalled jobs
maxStalledCount: 2, // after 2 stalls the job fails instead of looping
});
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return; // a second signal must not cancel the drain
shuttingDown = true;
console.log(`${signal} received β draining`);
try {
// false = do NOT force; wait for active jobs to finish and locks to release.
await worker.close(false);
await connection.quit?.();
console.log('drain complete');
process.exit(0);
} catch (err) {
console.error('drain failed', err);
process.exit(1);
}
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
worker.close(true) forces immediate closure and abandons active jobs β the equivalent of a cold shutdown. Use it only as a last resort inside a watchdog, never as the default.
Step 3 β Add a Watchdog So a Stuck Job Cannot Hang the Pod
If one job never finishes, close(false) never resolves and the orchestrator eventually sends SIGKILL β losing every other in-flight job as collateral. A watchdog forces closure just before that deadline so the rest drain cleanly.
const GRACE_MS = Number(process.env.DRAIN_GRACE_MS ?? 100_000); // < terminationGracePeriodSeconds
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
const forced = setTimeout(async () => {
console.warn('drain exceeded budget β forcing close');
await worker.close(true); // abandon what is left; those jobs will stall and re-run
process.exit(1); // non-zero so the deploy surfaces it
}, GRACE_MS);
forced.unref();
await worker.close(false);
clearTimeout(forced);
process.exit(0);
}
Exiting non-zero on a forced close is deliberate: it makes the event visible in pod restart reasons and dashboards instead of blending into normal shutdowns.
Step 4 β Extend Locks from Long Jobs
For jobs longer than lockDuration, extend the lock from inside the processor. BullMQ exposes this through job.extendLock, and the pattern doubles as a natural checkpoint.
async function processor(job) {
const total = job.data.frames;
for (let i = job.data.cursor ?? 0; i < total; i += 100) {
await transcodeChunk(job.data.assetId, i, 100);
// Renew the lock and publish progress β progress is durable, so a re-run
// after a stall can skip the frames already done.
await job.extendLock(job.token, 60_000);
await job.updateProgress({ cursor: i + 100 });
if (shuttingDown) {
// Re-queue with the cursor so the next worker resumes rather than restarts.
await job.moveToDelayed(Date.now() + 1000, job.token);
return;
}
}
}
updateProgress is the only durable place to record a cursor without a second store, and it is readable from job.progress on the re-run. Without it, a stalled transcode restarts at frame zero, which is exactly the duplicate work the drain exists to avoid.
Step 5 β Configure the Process Manager to Wait
BullMQ's careful drain is worthless if the supervisor kills the process after 1.6 seconds.
// ecosystem.config.js (PM2)
module.exports = {
apps: [{
name: 'transcode-worker',
script: 'worker.js',
instances: 4,
kill_timeout: 120_000, // default is 1600ms β the usual culprit
listen_timeout: 10_000,
wait_ready: false,
}],
};
# Kubernetes equivalent
spec:
terminationGracePeriodSeconds: 120 # > DRAIN_GRACE_MS + exit overhead
containers:
- name: worker
command: ["node", "worker.js"] # NOT `npm start` β npm swallows signals
Running the process directly rather than through npm start matters: npm forwards signals inconsistently, so SIGTERM frequently never reaches Node and the drain never begins. Use node worker.js or an init shim such as tini as PID 1.
Step 6 β Choose lockDuration and Concurrency Together
These two settings interact in a way that is easy to miss. lockDuration bounds how long a job may run without a renewal; concurrency bounds how many jobs one process holds. Node's single-threaded event loop ties them together: a CPU-bound job blocks the loop, which blocks the renewal timer, which lets the lock expire on a job that is actively running.
| Job shape | Concurrency | lockDuration | Why |
|---|---|---|---|
| I/O-bound (HTTP, DB) | 10β50 | 30s | Renewals interleave freely between awaits |
| Mixed | 4β10 | 60s | Some blocking; give renewals more slack |
| CPU-bound in-process | 1β2 | 120s+ | The loop stalls; long lock is the only safety |
| CPU-bound in a worker thread | 4β8 | 60s | Offloading keeps the main loop responsive for renewals |
The pathological case is CPU-bound work at high concurrency: a 20-second synchronous transcode with lockDuration: 30000 and concurrency: 20 will renew nothing for twenty seconds, and any job whose lock was already half-expired stalls while it is still running. The result is two workers processing the same job concurrently β worse than a duplicate after a crash, because both are live and writing at once. Push CPU-heavy work into worker_threads or a child process, or drop concurrency to 1 and scale out with more processes instead.
Verification
- Drain finishes cleanly.
kill -TERM <pid>under load; logs must showdrain completeand the process must exit 0 with no stalled-job warnings afterwards. - No stalls follow a deploy.
redis-cli LLEN bull:transcode:waitshould not jump after a rollout, and thestalledevent count must stay at zero. - Locks are released, not expired. Watch
bull:transcode:lock:*keys disappear at shutdown rather than lingering until TTL. - The watchdog fires when it should. Deploy a deliberately hanging job, send
SIGTERM, and confirm the forced close happens inside the grace period with a non-zero exit.
// Observability: count stalls so a regression is visible, not anecdotal
const events = new QueueEvents('transcode', { connection });
events.on('stalled', ({ jobId }) => {
console.warn(`stalled job ${jobId}`);
stalledCounter.inc(); // alert on any sustained non-zero rate
});
Gotchas & Edge Cases
lockDuration shorter than the job duration causes stalls with no deploy at all. The job keeps running while another worker picks it up, producing genuine concurrent duplicate execution. Set lockDuration above p99 duration or extend it from inside the processor.
Every worker runs the stalled checker. stalledInterval applies per worker, so a large fleet checks frequently in aggregate. That is usually fine, but a very short interval combined with a marginal lockDuration turns transient GC pauses into stall storms.
close() does not stop a QueueScheduler or QueueEvents connection. Close those explicitly, or the process stays alive with an open Redis handle and the orchestrator eventually kills it.
Concurrency multiplies the drain. With concurrency: 20 and 30-second jobs, a drain can take 30 seconds β but with unlucky timing it holds twenty jobs, any one of which can be the slow one. Size the grace period against the p99 of the slowest job class on that worker, not against the average.
maxStalledCount decides whether a poison job loops forever. Leave it at 1β2 so a job that repeatedly stalls fails and becomes visible rather than cycling. Pair it with the quarantine practices in dead-letter queues and poison-message handling.
Related
- Graceful Shutdown & Worker Deployments β the general drain model these APIs implement.
- Handling SIGTERM in Celery workers β the same problem in Python, with broker-side acknowledgement instead of locks.
- Zero-downtime worker deploys on Kubernetes β grace periods, surge, and disruption budgets around this drain.
- Configuring BullMQ concurrency limits for high throughput β how concurrency choices change the drain window.