Queues · intermediate
How to verify poison messages are isolated
Playbook for verifying that a poison message in a BullMQ-based queue is correctly isolated so it cannot consume normal worker capacity. Walks through triage of stalled-job signals, queue-side evidence collection, decision points on retry vs. delayed vs. failed state, and a regression check that proves the bad message is parked without starving healthy jobs.
The symptoms
- •One specific job repeatedly transitions to stalled then re-enters the queue, while other jobs in the same queue continue to process normally.
- •Worker concurrency is saturated by a small subset of jobs that never reach the completed or failed terminal state on their own.
- •Queue depth grows despite workers being present and idle capacity being reported, because the same message id keeps being picked up.
- •Logs show repeated attempts on the same job id with no progress on its data, while sibling job ids advance to completion.
Likely causes
- •A job whose handler throws an uncaught error before any retry counter is incremented, so it loops as stalled rather than moving to a failed bucket.
- •Misconfigured lockDuration or stalledInterval such that a failing job is released back to the wait list faster than its attempts counter can advance.
- •Worker process is being killed or sandboxed between attempts without calling moveToFailed, leaving the job re-queued indefinitely.
- •Handler depends on an external dependency that is permanently unavailable for that payload, but the error is swallowed or classified as transient.
First ten minutes
- 01Identify the offending job id by comparing stalled event counts per job in the queue's event stream; isolate the single id with the highest stalled count.
- 02Confirm that other job ids in the same queue are progressing, to rule out a worker outage rather than a poison message.
- 03Pull the job's data and attemptsOnFailed fields and write them down before any mutation, so you have a baseline to compare against after the fix.
- 04Decide which isolation path applies: move-to-failed with a reason, delayed retry with backoff, or manual parking in a dead-letter set.
Evidence to collect
- •Stalled event count per job id from the BullMQ QueueEvents stream, scoped to the queue name under investigation.
- •The job's attemptsMade, attemptsOnFailed, and timestamp values at the moment of the most recent stalled transition.
- •The error or exception emitted by the worker handler for that job id, captured at the moment of failure.
- •Queue depth and completed-per-minute counters before and after the suspect job is removed, to verify throughput is restored.
Where to look
- •The BullMQ stalled jobs mechanism documented at https://docs.bullmq.io/guide/jobs/stalled, specifically the lockDuration and stalledInterval interaction.
- •The QueueEvents stalled listener for the affected queue, which emits the jobId and the previous lock holder context.
- •The job's stored record in Redis under the queue key prefix, where attemptsMade and failedReason are persisted alongside the job payload.
- •The worker's own logs for the offending job id, filtered to error and stalled lines only, to compare handler output against queue-side transitions.
Diagnostic steps
- 01Stream QueueEvents stalled for the queue and group events by jobId; a single jobId with a count significantly above its peers is the candidate poison message.
- 02Compare the candidate's attemptsMade against its attemptsOnFailed; if attemptsMade is not incrementing between stalled events, the handler is exiting before retry accounting runs.
- 03Inspect the handler's error for the candidate jobId; if the error class is deterministic (parse error, schema violation, missing resource), classify it as permanent rather than transient.
- 04Verify lockDuration is greater than the longest expected handler runtime; if lockDuration is too short, every long-running job will be flagged stalled even when it is healthy.
- 05Check whether a separate worker is competing for the same queue; concurrent workers on the same jobId without coordination will surface as stalled churn rather than progress.
Common mistakes
- •Increasing worker count to compensate for stalls caused by a poison message, which only multiplies the redundant pick-ups of the bad job.
- •Reducing lockDuration to make stalled jobs move faster, which makes healthy long-running jobs look stalled and worsens the symptom.
- •Treating every stalled event as a retry-able failure and removing the attemptsOnFailed guardrail, which hides permanent errors as transient ones.
- •Deleting the job from the queue without recording its id and reason, leaving no audit trail for the next on-call engineer.
Safe fixes
- •If attemptsMade is incrementing but the error is deterministic, call moveToFailed on the job with the captured exception and a reason string so it is parked and stops competing for capacity.
- •If attemptsMade is not incrementing because the handler aborts early, first patch the handler to rethrow and let BullMQ's retry path count the attempt, then re-evaluate.
- •If lockDuration is shorter than observed handler runtime, raise it to a value above the p99 handler duration for that queue, then watch the stalled count for that jobId drop to zero.
- •If the payload is the root cause and cannot be fixed in code, move the job to a dedicated dead-letter set keyed by the original jobId so it is preserved for offline analysis.
Prove the fix
- 01The offending jobId no longer appears in the QueueEvents stalled stream for the queue; stall count for that id remains at zero across one full lockDuration window.
- 02Queue depth decreases over time and completed-per-minute returns to the pre-incident baseline, proving the worker capacity that was consumed by the poison message is released.
- 03A regression check that re-introduces a synthetic poison payload must produce a stalled event followed by a terminal failed transition within attemptsOnFailed plus one cycle, not an unbounded loop.
- 04Other job ids in the same queue continue to complete during the regression check, demonstrating that isolation did not block healthy traffic.
Prevention and next steps
- •Set attemptsOnFailed to a finite value per queue and alert when any jobId reaches it, so a permanent failure is escalated rather than silently retried.
- •Size lockDuration from observed handler p99 latency plus a safety margin, and review it whenever handler code paths change.
- •Wrap handlers so that any thrown error reaches BullMQ's retry accounting path, and log the error class alongside jobId for post-incident search.
- •Maintain a dead-letter destination keyed by original jobId and failed timestamp, so isolated poison messages remain inspectable rather than being silently discarded.
Safe commands and checks
node -e "const {QueueEvents}=require('bullmq');const q=new QueueEvents('<queue-name>',{connection:{host:'<redis-host>',port:<port>}});q.on('stalled',({jobId,prev})=>console.log(jobId,prev));"
node -e "const {Queue}=require('bullmq');const q=new Queue('<queue-name>',{connection:{host:'<redis-host>',port:<port>}});q.getJob('<job-id>').then(j=>console.log({attemptsMade:j&&j.attemptsMade,attemptsOnFailed:j&&j.attemptsOnFailed,failedReason:j&&j.failedReason}));"
node -e "const {Queue}=require('bullmq');const q=new Queue('<queue-name>',{connection:{host:'<redis-host>',port:<port>}});q.getJobCounts().then(c=>console.log(c));"