Queues · advanced
Queue visibility timeout expiry: explain duplicate delivery during slow work
Visibility timeout expiry is a Queues failure mode where a worker's processing time exceeds the configured invisibility window, causing the same job to be re-enqueued and delivered to another worker. The result is duplicate side effects, idempotency violations, and unstable throughput. BullMQ's stalled-jobs documentation describes this recovery path explicitly and provides the configuration surfaces engineers use to bound it.
The symptoms
- •Workers report the same job id or message id completing twice in metrics, with completion timestamps closer than the full retry interval.
- •Downstream services receive duplicate writes, notifications, or charge attempts correlated with workers that took longer than the configured visibility window.
- •Application logs show a 'stalled' or 'job recovered' event for a job that the original worker still believes is in flight.
- •Throughput plateaus or declines even when worker CPU and I/O appear underused, because duplicate work cancels out real progress.
- •Retry counters increment without a corresponding failure exception in worker code, indicating the broker re-offered the job rather than the worker releasing it.
Likely causes
- •Job processing logic exceeds the configured lock duration or visibility timeout, after which the broker assumes the worker has died and re-queues the job.
- •Long-running external calls (database transactions, HTTP requests to third-party APIs, file processing) run synchronously without extension or heartbeat.
- •GC pauses, event-loop stalls, or throttled CPU under container quotas cause the worker to miss its lock-renewal deadline, even though the job code is otherwise correct.
- •Concurrency is misconfigured relative to worker capacity, so each in-flight job takes longer than expected because resources are oversubscribed.
- •The job is moved between queues or workers without preserving its lock state, triggering the stalled-job recovery path even though the worker is healthy.
First ten minutes
- 01Identify which queue and worker pair is showing duplicates by filtering metrics or logs on the specific queue name and worker hostname, not on generic application errors.
- 02Capture the job's wall-clock processing duration from worker instrumentation and compare it against the configured lock duration or visibility timeout for that queue.
- 03Pull the 'stalled' or 'job recovered' events from BullMQ's stalled-jobs surface and align their timestamps with the duplicate completion events observed downstream.
- 04Inspect worker host metrics (CPU steal time, GC pause, event-loop lag, throttling) for the same window to determine whether processing genuinely exceeded the timeout.
- 05Decide whether to shorten the visible symptom by raising the lock duration or by adding heartbeats, before attempting deeper refactors of the job handler.
Evidence to collect
- •Worker log lines that include job id, queue name, lock duration, and observed processing time for the duplicated job.
- •BullMQ stalled-job events showing the same job id being marked stalled and then moved back to the wait list.
- •Downstream service logs or audit trails showing two write attempts with identical idempotency keys but different worker identifiers.
- •Host-level metrics (CPU throttling, GC pause duration, event-loop lag) for the worker process during the duplicated job's execution window.
- •Queue configuration values: lockDuration, stalledInterval, maxStalledCount, and concurrency for the affected worker class.
Where to look
- •The boundary between the worker process and the BullMQ broker, specifically the lock-renewal heartbeat described in the stalled-jobs documentation.
- •Queue definitions and worker bootstrap code where lockDuration and stalledInterval are set, since these values directly determine the invisibility window.
- •Worker-side timing around external dependencies (database, HTTP, filesystem) where blocking waits can silently exceed the configured window.
- •Container or VM orchestration limits (CPU quotas, memory limits) that can throttle the worker and stretch its processing time.
- •Downstream write paths that accept an idempotency key, because the duplicate delivery only becomes a correctness problem when the side effect is not naturally idempotent.
Diagnostic steps
- 01Reproduce by enqueueing a job that intentionally sleeps beyond the configured lockDuration and confirm whether the same job id appears on a second worker within the stalledInterval window.
- 02Compare observed processing time P95 against lockDuration; if P95 is within 20 percent of the lock duration, treat the window as effectively exhausted under load.
- 03Check whether the duplicated job coincided with a GC pause, event-loop stall, or CPU throttle event by correlating worker timestamps with host metrics.
- 04Audit the job handler for synchronous external calls that lack a timeout shorter than lockDuration, and verify whether any of those calls can stall indefinitely.
- 05Verify that downstream write paths accept and honor an idempotency key derived from the job id, so that a recovered job cannot double-charge or double-notify.
- 06Determine whether concurrency is set higher than the worker host can service within lockDuration by measuring average in-flight job duration under nominal load.
Common mistakes
- •Increasing lockDuration indefinitely instead of bounding the actual work, which delays duplicate delivery rather than preventing it and can mask root-cause slowness.
- •Assuming duplicates are caused by 'at-least-once' delivery in the abstract rather than measuring whether a specific job actually exceeded the configured window.
- •Adding retries on top of duplicate delivery, which amplifies the number of side-effect attempts instead of consolidating them.
- •Catching exceptions inside the job handler and returning success, which prevents BullMQ from moving the job to failed and obscures the stalled-recovery signal.
- •Disabling stalled-job recovery entirely to silence the noise, which removes the only mechanism that re-delivers work from a truly crashed worker.
Safe fixes
- •If measured P95 processing time is consistently above lockDuration, raise lockDuration only to a value that comfortably exceeds P95 plus headroom, and re-measure after the change.
- •If the job handler performs long external work, move that work into a child job or background continuation so the active lock covers only the dispatch step.
- •Add a per-job heartbeat or progress update inside the handler so BullMQ's stalled-job detection can distinguish 'slow but alive' from 'dead'.
- •Bound every blocking external call with a timeout that is strictly less than lockDuration, so a stuck dependency fails fast instead of forfeiting the lock.
- •Make downstream side effects idempotent by deriving an idempotency key from the job id, so duplicate delivery during recovery does not change business state.
Prove the fix
- 01After the change, run a load test that intentionally holds a job longer than the previous lockDuration and confirm the same job id is NOT delivered to a second worker within one stalledInterval.
- 02Verify that under sustained production-like load, the duplicate-completion metric for the affected queue drops to zero over a representative window (for example, one hour).
- 03Confirm that downstream write paths receive exactly one write per logical job, observable via an idempotency key audit log or downstream dedup counter.
- 04Check that no 'stalled' events are emitted for jobs whose processing time stays below the new lockDuration, indicating the recovery path is no longer firing spuriously.
- 05Record the before-and-after P95 of worker processing duration, and confirm that headroom between P95 and lockDuration is at least 30 percent.
Prevention and next steps
- •Set lockDuration with explicit headroom over measured P95 processing time, and re-measure whenever the job handler changes.
- •Require every job handler to declare the side effects it performs and the idempotency mechanism that protects each one, reviewed as part of queue onboarding.
- •Alert when duplicate-completion rate for any queue exceeds a small threshold, so silent timeout drift is caught before it causes business-side duplication.
- •Keep external dependency timeouts strictly shorter than the shortest configured lockDuration, and document this constraint alongside queue definitions.
Safe commands and checks
node -e "const q=require('ioredis');const c=new q(process.env.REDIS_URL);c.llen('bull:wait').then(n=>{console.log('wait_list_length',n);return c.quit();})"
node -e "const q=require('bullmq');const c=new q.Queue('email',{connection:require('ioredis').createClient(process.env.REDIS_URL)});c.getJobs(['active','waiting','delayed']).then(j=>{console.log(JSON.stringify(j.map(x=>({id:x.id,name:x.name,ts:x.timestamp}))));return c.close();})"
grep -n "stalled" worker.log | awk '{print $1,$2,$NF}' | tail -n 50
grep -n "jobId" worker.log | sort -k7 | uniq -c | sort -nr | head -n 20
ps -o pid,pcpu,pmem,etime,comm -p <pid>
node --expose-gc -e "const v=require('v8');setInterval(()=>{const s=v.getHeapSpaceStatistics();console.log(JSON.stringify({used:s.map(x=>x.used_space_bytes)}))},1000)"