Back to Blog

BullMQ "Missing Lock for Job" Error in Multi-Instance Deployments

Published: July 29, 2026
BullMQ "Missing Lock for Job" Error in Multi-Instance Deployments

BullMQ missing lock for job error shows up almost exclusively after scaling from one worker instance to several, and the message reads like a genuine conflict — as if two workers grabbed the same job at once and are now fighting over who gets to finish it. Most of the time, that's not what happened at all. A single worker held the job the entire time; its lock simply expired in Redis while the worker was too busy with the job itself to check in and renew it, and BullMQ is reporting the expiration honestly, which reads a lot scarier than what actually occurred.

Short answer: BullMQ's job locks default to a 30-second expiration and need active renewal while a job runs — if your job's processor function blocks the event loop long enough that the renewal timer can't fire, the lock disappears out from under a worker that's still legitimately processing the job. Increase lockDuration to comfortably exceed your real job execution time, and move genuinely CPU-heavy work into sandboxed processors so the renewal check has room to run.

A rusty padlock and chain on an old wooden door showing clear decay, representing a BullMQ job lock that expired before the worker could renew it

Why BullMQ Reports Missing Lock for Job in Multi-Instance Deployments

BullMQ's own troubleshooting documentation names the mechanism plainly: a job's lock — the thing that proves a specific worker currently owns it — expires by default after 30 seconds unless the worker actively renews it while the job is still running. The most common reason renewal fails isn't a network problem or an actual second worker grabbing the job. It's the worker's own event loop being too busy, usually with CPU-intensive synchronous code inside the job's own processor function, to run the background timer responsible for extending the lock before it expires. By the time the worker finishes the job and tries to mark it complete, Redis has already let the lock go, and BullMQ reports exactly what it sees: a missing lock, worded in a way that sounds like contention even when there was only ever one worker involved.

(If you've been adding distributed-locking libraries or trying to coordinate workers more explicitly to prevent "two workers racing for the same job" — that's solving a problem you probably don't have. Check whether it was ever actually two workers before reaching for a bigger hammer.)

Fix 1: Increase lockDuration to Match Real Job Duration

The direct fix, once CPU starvation is confirmed as the cause, is giving the lock enough headroom for how long your job actually takes:

TypeScript
1// worker.ts — lockDuration set well beyond real job execution time, not the 30s default
2import { Worker } from 'bullmq';
3
4const worker = new Worker(
5  'report-generation',
6  async (job) => {
7    // a job that realistically takes 1-2 minutes to run
8    await generateReport(job.data);
9  },
10  {
11    connection: { host: process.env.REDIS_HOST, port: 6379 },
12    lockDuration: 300_000, // 5 minutes — comfortably beyond the worst-case job time
13  },
14);

The right number here isn't your average job time, it's your worst realistic one — a lockDuration tuned to the typical case still expires on the slower jobs that matter most, which is exactly when you'll see this error reappear under real production load rather than in testing.

A businessman with sticky notes covering his face, representing a worker's event loop too overloaded with synchronous work to renew its own job lock in time

Fix 2: Move CPU-Heavy Work Into Sandboxed Processors

Increasing lockDuration treats the symptom. If the actual cause is a genuinely CPU-intensive processor function blocking the event loop, sandboxed processors run the job in a separate Node process entirely, keeping the main process's event loop free to handle lock renewal (and everything else) regardless of how much CPU the job itself consumes:

TypeScript
1// worker.ts — sandboxed processor runs in a separate process, event loop stays free
2import { Worker } from 'bullmq';
3import path from 'path';
4
5const worker = new Worker(
6  'report-generation',
7  path.join(__dirname, 'report-processor.js'),
8  { connection: { host: process.env.REDIS_HOST, port: 6379 } },
9);
TypeScript
1// report-processor.js — runs isolated, doesn't block the main worker process's event loop
2module.exports = async (job) => {
3  return generateReport(job.data);
4};

This is the more durable fix for genuinely heavy processing, since it removes the underlying cause — event loop starvation — rather than just widening the window before the symptom reappears.

An intense tug-of-war competition with a cheering crowd, representing what a genuine lock conflict between two workers actually looks like — which is far rarer than it first appears

Distinguishing a Real Conflict From a False Positive

Before assuming CPU starvation, rule it out concretely: check whether the same job ID was genuinely started by more than one worker around the same time — that's an actual conflict, and vanishingly rare in a correctly configured setup. If only one worker ever touched the job, and the error correlates with a period of heavy synchronous processing rather than any sign of a second worker's involvement, lock expiration from starvation is almost certainly the explanation, not contention. Logging job start and completion with worker identity attached, even temporarily, turns this from a guess into a five-minute check.

The Opinion Part

Here's the position worth stating plainly: this error is one of the clearest examples in distributed systems of a message that describes the mechanism accurately while inviting the wrong mental model. "Missing lock" sounds like a race condition — two actors fighting over a resource — when the actual event, most of the time, is a timeout that a single well-behaved worker simply didn't beat. Scaling from one instance to several doesn't create new failure modes out of nothing; it just makes existing ones — like a processor function that quietly blocks the event loop — visible for the first time, because a single instance rarely gets busy enough to matter. Splitting into multiple workers is, in a real sense, a small version of the same tradeoff behind microservices generally: more moving parts, and more chances for a real bottleneck that was always there to finally get loud enough to notice — the same underlying theme our guide on horizontally scaling NestJS across multiple instances covers from the application-state side rather than the job-queue side.

Conclusion

If BullMQ is throwing "missing lock for job" errors after scaling to multiple workers, don't reach for distributed locking or job-deduplication logic first — check whether it's actually CPU starvation preventing lock renewal, which is the far more common cause. Increase lockDuration to match your real worst-case job time, move genuinely heavy processing into sandboxed processors so the event loop stays free, and confirm with worker-identity logging whether you're looking at a real conflict at all before treating it as one.

If jobs are also getting stuck in a waiting state rather than erroring out entirely, that's a related but distinct BullMQ failure mode worth ruling out separately — a missing lock and a job that never starts processing point at different parts of the same pipeline.

Give the lock the time the job actually needs, and let the error stop showing up for a problem that was never really a conflict in the first place.

Frequently Asked Questions

It means the worker tried to mark a job as completed or failed, but the lock it held on that job — proof that it, and not some other worker, owns the right to finish it — had already expired in Redis by the time it got there. BullMQ's default lock expires after 30 seconds and needs to be actively renewed while a job is still running; if that renewal doesn't happen in time, the lock disappears out from under the worker.

No, and this is the most common misdiagnosis. The far more frequent cause is a single worker's event loop being too busy — usually from CPU-intensive synchronous work inside the job processor — to run the background timer that renews its own lock before the default 30-second expiration hits. The job was never actually contested; the worker just took too long between renewal checks.

Increase lockDuration to comfortably exceed your job's real execution time — a job that regularly takes a couple of minutes needs a lock duration well beyond 30 seconds, not a duration tuned to the average case. For genuinely CPU-heavy processing, moving the work into BullMQ's sandboxed processors (which run in a separate Node process) keeps the main event loop free to renew locks on time regardless of how busy the processing itself gets.

Check whether the same job ID was actually picked up and started by more than one worker around the same time — that's a genuine conflict. If only one worker ever touched the job and the error appears specifically after a period of heavy synchronous processing, CPU starvation preventing lock renewal is almost always the explanation, not two workers racing for the same job.

There's no single universal number — the right value depends on your slowest realistic job, not your average one. A common practical approach is setting lockDuration to at least double your worst-case job duration, and leaving lockRenewTime at its default fraction of that value so renewal happens well before expiration rather than right at the edge of it.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio