Job queues and BullMQ's job lifecycle
A queue runs each job at least once, not exactly once: a dead worker's job runs again, and that costs no attempt.
Predict first
Before you read: predict
ExamHall adds a grade-attempt job with attempts: 2. The worker process running it is killed (SIGKILL) partway through the handler. A second worker process on the same queue stays up; all other settings are BullMQ's defaults. From then on, every run of the handler throws an error.
How many times does the handler start in all, and where does the job end?
The 30-second version
The API adds a job and returns; workers take it later. BullMQ keeps each job's state in Redis: waiting, delayed, active, completed or failed. A thrown error spends an attempt; the job retries after its backoff. If its worker dies, the lock lapses: the job stalls and goes back to waiting without spending an attempt, so it can run twice.
A queue runs each job at least once, not exactly once: a dead worker's job runs again, and that costs no attempt.
How it works
Step 1 of 11
Analogy: A picture-framing workshop
Orders are tickets on a rail. A framer takes a ticket to the bench and hangs a "working until" card over it, stamping a new time every few minutes. A botched mount marks a box on the ticket, and the ticket goes on the "try again later" hook; all boxes marked, it goes to the rejects tray. A supervisor walks round: a card whose time has passed means the framer has gone, so the ticket goes back on the rail with a tally mark, and its boxes stay as they were.
| In the workshop | In BullMQ |
|---|---|
| a ticket on the rail | a waiting job (step 2) |
| the "working until" card, re-stamped while the framer works | the lock and its renewal (step 3) |
| a botched mount: one box marked, ticket on the "later" hook | a throw spends an attempt; backoff in delayed (steps 4–5) |
| an expired card: ticket back on the rail, a tally mark, boxes untouched | the stalled-job check and stall counter (steps 7–8) |
| a framer with three clamps, three frames drying at once | concurrency: 3 (step 9) |
| the next framer glues the glass again after the first went home ill | at-least-once (step 11) |
In real terms: a job is state in Redis plus a lock with an expiry; a handler that stops renewing the lock gets its job run again.
Breaks down when: the next framer can see the glass is already glued; BullMQ cannot see what your handler already did. Wrong inference: "the queue will skip the part that finished." It runs the whole handler again.
Contrasting case: a counter where the customer waits while the frame is made. One absent framer loses the order and the customer waited throughout: that is work done inside the request.
Examples, run for real
4.1 ExamHall: submit returns before grading
EXH-1350: at exam close, final-submit requests timed out, because the handler graded the attempt inside the request. MR !1550 adds a job and returns 202.
// after MR !1550: the request adds a job and answers at once
const job = await grading.add('grade-attempt', { attemptId }, { attempts: 3, backoff: { type: 'exponential', delay: 1000 } });
res.writeHead(202).end(JSON.stringify({ attemptId, status: 'grading' }));
4.1 before: grading still running, response sent false · after: 202 {"attemptId":"a-5521","status":"grading"} · job finished when 202 arrived false
4.1 before, once grading ends: 200 {"score":71} · after: job completed · events added waiting active completed
With grading held open, the old request still waits; the new one has answered, and the worker completes the job later (steps 1–2).
4.2 ExamHall: two failures, then a score
The scoring service fails twice, then answers; the job has attempts: 4 and exponential backoff from 100 ms.
4.2 events added waiting active delayed waiting active delayed waiting active completed
4.2 attemptsMade seen by each run 0,1,2 · final completed · attemptsMade 3 · backoff before attempts 2,3,4 (Backoffs.calculate): 100, 200, 400 ms
Each throw sends the job to delayed for its backoff, then back to waiting (steps 4–5). The last line is BullMQ's own backoff calculation, not a measured time.
4.3 Proof domain, ticketing: a seat map rendered twice
A render-seat-map worker process is killed after it rendered and before it returned.
4.3 events added waiting active waiting stalled active completed
4.3 handler starts 2 (killed run + rerun) · the rerun saw attemptsMade 0 · stalledCounter 1 · final completed
The stall check put the job back in waiting (step 7). The rerun saw no attempt spent and one stall counted (step 8). The render ran twice (step 11).
4.4 Proof domain, ticketing: a delayed release and one worker's slots
4.4 release-hold state right after add: delayed · events added delayed waiting active completed · final completed
4.4 one worker, concurrency 4, 6 jobs held on an unanswered call: active 4 · waiting 2
The worker was free, so its fetch moved the due job to waiting and took it (steps 2, 10). One worker never runs more than its concurrency (step 9).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
A send-tickets job is added with attempts: 1 and backoff: { type: 'exponential', delay: 2000 }. Its first run throws, because the mail API is down.
Which state does the job enter right after that run?
Question 2 of 5
One worker with concurrency: 1 is running a long issue-batch job. A release-hold job is added with delay: 200 (milliseconds). Three seconds later the batch job is still running.
What is release-hold's state?
Question 3 of 5
Two refund worker processes each run with concurrency: 3. Eight refund jobs are added; each handler awaits a card provider that has not answered yet.
How many of the jobs are active?
Question 4 of 5
A settle-sale job adds each sale's amount to the event's revenue with HINCRBY revenue:e42 total <amount>; jobs have attempts: 3. After a worker process was killed mid-job, one sale was counted twice.
Which change keeps the revenue right whether the worker dies just before or just after the revenue write?
Question 5 of 5
Nightly sales-report jobs (attempts: 3) run on two worker processes. Each handler fetches the day's sales, then computes the whole report in memory before it returns; that computation takes several times the workers' lockDuration. No process crashes, yet each report's handler starts twice, the job ends failed with "job stalled more than allowable limit", and both workers log "Missing lock for job".
Which is the bug?
Go deeper
Pro tips
- On SIGTERM,
await worker.close()before the process exits. It stops taking new jobs and waits for the active ones, so a deploy does not turn every running job into a stall. When: rolling deploys, pod evictions with a grace period (steps 7–8). Verify (RAN,tips.mjs, 50 runs, 1 distinct):close() resolved while the job ran false · after close: running job completed (stalled 0) · next job waiting · handler runs 1. A job longer than the grace period still stalls (BullMQ, Going to production). - Set
removeOnCompleteandremoveOnFail. By default BullMQ keeps every finished job in Redis. When: any queue with steady traffic. Verify (RAN,tips.mjs):five completed jobs kept in Redis: default 5 · removeOnComplete { count: 2 } 2.
Source
- BullMQ docs, Stalled Jobs: https://docs.bullmq.io/guide/workers/stalled-jobs · fetched 2026-09-28. "the worker needs to periodically notify BullMQ that it is still working on the job"; "if the CPU is very busy (due to the process being very CPU intensive), the worker may not have time to renew the lock"; "A stalled job is moved back to the waiting status and will be processed again by another worker"; "if it has reached its maximum number of stalls, it will be moved to the failed set". The page does not say whether a stall spends an attempt; that was executed. Valid as of 2026-09-28.
- BullMQ 6.3.9 type definitions,
dist/esm/interfaces/worker-options.d.ts(installed package):maxStalledCountis the "Amount of times a job can be recovered from a stalled state to thewaitstate. If this is exceeded, the job is moved tofailed", default 1;lockDurationdefault 30000 and "If the lock is lost, the job will be eventually be picked up by the stalled checker and move back to wait";stalledIntervaldefault 30000;concurrencyis the "Amount of jobs that a single worker is allowed to work on in parallel", default 1 (steps 3, 7–9). The Lua scriptmoveStalledJobsToWait-9.luaincrements the job's stall count and checks "that the lock is also missing" (step 8). Valid as of 2026-09-28. - BullMQ docs, Retrying failing jobs: https://docs.bullmq.io/guide/retrying-failing-jobs · fetched 2026-09-28. Retries need "the
attemptssetting with a value larger than 1"; fixed backoff retries "every attempt 3000 milliseconds after the previous attempt" for a 3000 ms delay; exponential retries "after2 ^ (attempts - 1) * delaymilliseconds"; a failed job "may stay in the failed set forever, or it could be automatically removed". The page's sentence that a throwing processor moves the job "to the failed set" describes its first scenario, the default single attempt; the same page goes on: "In order to activate automatic job retries you should use the attempts setting with a value larger than 1", and "Retried jobs will respect their priority when they are moved back to waiting state". Valid as of 2026-09-28. - BullMQ 6.3.9 source,
dist/esm/classes/job.jslines 445–497 (installed package):shouldRetryJobretries whenattemptsMade + 1 < opts.attempts;moveToFailedthen callsbackend.moveToDelayed(…)when the backoff gives a non-zero delay, otherwisebackend.retryJob(…)(back to waiting), and only on the last attemptbackend.moveToFailed(…).dist/esm/commands/moveToActive-11.luaincludespromoteDelayedJobs(lines 52–59): a due delayed job is moved to waiting when a worker fetches its next job. - BullMQ docs, Concurrency: https://docs.bullmq.io/guide/workers/concurrency · fetched 2026-09-28. Local concurrency "determines how many jobs are allowed to be processed concurrently ... for that instance"; more workers are "the recommended way", giving "higher availability"; concurrency "is only possible when workers perform asynchronous operations"; CPU-intensive work is better in "Sandboxed processors". Valid as of 2026-09-28.
- BullMQ docs, Delayed jobs: https://docs.bullmq.io/guide/jobs/delayed · fetched 2026-09-28. Delayed jobs go to a "special 'delayed set'"; "it is not guaranteed that the job will be processed at the exact delayed time specified, as it depends on how busy the workers are when the time has passed". Valid as of 2026-09-28.
- BullMQ docs, Architecture: https://docs.bullmq.io/guide/architecture · fetched 2026-09-28. "wait" is "a waiting list, where all jobs must enter before they can be processed"; "delayed" means "the job is waiting for some timeout or to be promoted"; "active" jobs "are currently being processed" (step 2). Valid as of 2026-09-28.
- BullMQ docs, Going to production: https://docs.bullmq.io/guide/going-to-production · fetched 2026-09-28. "listen for both
SIGINTandSIGTERMsignals to close gracefully"; "If a worker is killed without waiting for their jobs to complete, these jobs will be marked as stalled and processed automatically when new workers come online"; finished jobs are "kept forever" by default;maxRetriesPerRequestmust benullfor workers on ioredis (steps 7, 11; tips 1–2). Valid as of 2026-09-28. - BullMQ docs, Idempotent jobs: https://docs.bullmq.io/patterns/idempotent-jobs · fetched 2026-09-28. "it should not make a difference to the final state of the system if a job successfully completes on its first attempt, or if it fails initially and succeeds when retried". Valid as of 2026-09-28.
Related lessons
- Read first: Redis data structures and atomicity
- Related: Blocking the event loop: CPU work, sync APIs, ReDoS, and measuring it
- Related: Worker threads vs cluster vs horizontal scaling
- Related: Queue vs log vs pub/sub, BullMQ, RabbitMQ and SQS vs Kafka and JetStream
- Related: Idempotent consumers and the exactly-once myth
Checked against the tools and docs as of 2026-09-28.