cswork / libraryOpen practice workspace

Articles

BullMQ Interview Questions: Retries, Stalled Jobs, and Worker Concurrency

Learn BullMQ retry and stalled-job behavior, diagnose missed lock renewal, and explain local versus global worker concurrency with a payment example.

A BullMQ interview prompt may start with a queue that retries failed work and end with a payment submitted twice. The interesting question is not whether attempts: 3 is syntactically valid. It is whether you can explain why a worker might lose its lock, why the job returns to waiting, and how the application keeps the business outcome correct when another processor begins. The queue's retry, stall, and concurrency settings govern different boundaries.

Imagine a capture-payment job. It sends a request to a provider, then writes a local receipt. A busy Node.js process delays BullMQ's lock renewal. Another worker may later receive the same job even though the first provider call succeeded. To reason through that situation, trace the job's state and the provider's effect separately. Practice the queue-level part with Implement a Concurrent Payment Queue with Visibility Timeouts.

Evidence boundary: Official facts below come from BullMQ documentation checked in September 2026. Candidate reports are not used to imply that a particular employer asks these questions. Inference covers the payment example, proposed limits, and testing decisions; those depend on your workload and provider contract.

What does BullMQ retry after a failure?

BullMQ's retry guide identifies two paths to a failed job: the processor throws an exception, or a stalled job exceeds its allowed stalled count. For automatic retries, set attempts greater than one when adding the job or in default job options. That number is the total permitted attempts, not the number of retries after the first run. If you set three attempts, plan for the initial run and at most two automatic retries from processor failures. The guide also warns that thrown exceptions should be Error objects, not arbitrary strings.

Backoff changes when another attempt becomes eligible. BullMQ documents fixed and exponential strategies, with optional jitter. Without a backoff setting, a failed job can be retried without delay. That can turn a provider outage into a burst of repeated calls. Conversely, a long backoff might miss a business deadline. The right interview answer names a recovery target and the provider's rate or error behavior before choosing delay values.

ObservationQueue responseApplication question
Processor throws a transient ErrorRetry if attempts remainIs the side effect safe to repeat?
Processor rejects an invalid requestOrdinary retry may repeat a permanent failureShould this become an explicit terminal outcome?
Lock is not renewedJob can be marked stalled and moved for processing againIs the first execution actually stopped?
Attempts are exhaustedJob remains failed according to retention settingsWho investigates and reconciles the business state?

BullMQ has an UnrecoverableError mechanism for a condition that should not consume ordinary retries. Use it only when the failure truly cannot be repaired by waiting. A malformed immutable input might qualify; a temporary HTTP 503 does not. Also distinguish automatic retries from manually calling Job.retry() on an already failed or completed job. The latter is an operational action with its own preconditions, not a substitute for a designed retry policy.

Why can a job stall while its processor is still running?

When a BullMQ worker takes a job, it holds a lock and periodically renews it. BullMQ's stalled-job guidance explains that a process busy with CPU-heavy work may fail to renew that lock because the Node.js event loop cannot run the renewal work promptly. A crashed worker or a connection problem can create a similar symptom. Once BullMQ determines the job is stalled, it can move the job back to waiting or fail it after maxStalledCount is exceeded. The default maximum stalled count documented by BullMQ is one.

Crucially, stalled is an event, not a durable job state. A dashboard that queries a job's current state might show waiting or failed after the event. More importantly, the event does not prove the old processor has stopped its external side effects. That is why a job that loses a lock is a potential duplicate-effect case, not merely a scheduling nuisance.

Walk an interviewer through this timeline:

  1. Worker A receives capture-payment(order-42) and begins CPU-intensive invoice rendering before its provider call.
  2. The event loop is blocked long enough that A misses lock renewal.
  3. BullMQ emits a stalled event and places the job back into waiting if the configured limit permits.
  4. Worker B receives the same logical job and calls the payment provider.
  5. A resumes and may also call the provider or try to finish a job whose lock it no longer owns.

The exact race depends on processor code and timing, but the business conclusion is stable: do not assume a queue lock provides an exactly-once payment guarantee. Use a stable order_id or payment-operation ID, a durable state transition, and a provider idempotency key when supported. Record enough information to reconcile an uncertain provider response. If the provider call succeeds and the local receipt write fails, a later run must determine what actually happened before sending another charge.

The first debugging step is to find why renewal was missed: synchronous CPU work, long garbage-collection pauses, Redis connectivity, overloaded host, or a true process death. Raising the stall limit before measuring that cause can hide the symptom and postpone failed-job visibility. BullMQ recommends allowing the event loop to run, and documents sandboxed processors as a way to separate CPU-heavy execution from the main worker process. Those are design choices, not proof that duplicates can no longer occur.

How do local and global concurrency differ?

The worker concurrency option controls how many jobs one Worker instance may have in progress. BullMQ's documentation explains that increasing it helps when processors spend time awaiting I/O. It does not create CPU parallelism for a synchronous JavaScript loop on one event loop. To use more cores or isolate a CPU-heavy processor, run multiple processes or use sandboxed processors, then measure their Redis, memory, and downstream load.

A queue's global concurrency is a separate cap across worker instances. Suppose three workers each set local concurrency to ten. Their local limits could permit up to thirty in-flight jobs, but queue.setGlobalConcurrency(4) caps the queue at four across the fleet. Setting a local value of twenty on one instance cannot override that four-job global cap. This is a useful interview distinction because a candidate may say “set concurrency to four” without specifying whether the limit protects one process or a shared dependency.

ControlScopeGood reason to use itFailure if misunderstood
Worker concurrencyOne Worker instanceOverlap I/O waitsCPU-bound code still blocks lock renewal
More Worker processesFleet capacity and availabilityUse cores and survive a process failureMore simultaneous provider/database demand
Global concurrencyAll Workers on one queueProtect a shared downstream capLocal tuning alone cannot exceed it
Provider idempotency keyOne business operation at providerMake repeated attempts safeKey retention and reconciliation still matter

For a workload with average provider latency of 500 ms, do not simply calculate a concurrency number from latency and declare victory. Measure p95 and p99 latency, throughput, Redis utilization, event-loop delay, memory, and provider rate-limit responses under representative load. Increasing concurrency can improve I/O throughput until a database pool, external service, or Redis connection becomes the bottleneck. Then it can increase tail latency and stalled events.

What would you change for CPU-bound work?

If the processor converts a large PDF or compresses images synchronously, a high local concurrency value is misleading. Many jobs may be claimed, yet the single event loop can spend most of its time in one computation. The immediate options are to make the computation yield appropriately, use a sandboxed processor, or place CPU work in separate processes with bounded fleet size. Which is best depends on the library's execution model and startup overhead.

Explain how you would validate the change. Run the same job mix before and after, with enough duration to include peak input sizes. Compare completed jobs per minute, p95 queue wait, event-loop delay, stalled events, process memory, and provider errors. If stalls disappear but completion slows dramatically, the change solved one symptom while creating a capacity problem. A sound answer reports the trade-off instead of calling a larger worker count automatically better.

Also define a shutdown path. BullMQ's production guidance discusses closing workers gracefully, but a process manager can still terminate the process before a long job finishes. The job body must remain safe to repeat after abrupt death. Test both controlled shutdown and a hard kill at the uncertain side-effect boundary. A green graceful-shutdown test alone does not cover host failure.

Which investigation would distinguish a retry from a stall?

Start with the job ID, logical business key, attempt count, state transitions, and worker logs. A processor exception should have an error and a retry path. A stalled event points to missed lock renewal; inspect event-loop delay, CPU, Redis connection health, and which worker held the job. If two workers log the same job around the same time, compare timestamps with provider request IDs. The question is not just how many times the processor started, but how many business effects occurred.

For capture-payment, build a reconciliation view keyed by order: queue attempts, provider operation ID, local receipt, and final payment status. A row with a provider capture but no local receipt needs repair. Two provider captures need compensation or investigation. A job in the failed set does not necessarily mean the provider did nothing; it may mean the worker could not record success after the provider did its work.

An effective failure test deliberately pauses the event loop or prevents lock renewal while a provider stub records requests. Assert the final business state after the job is reprocessed. Then inject a processor exception before the provider call and another after provider acceptance. The expected result is one logical charge, even if there are multiple attempts. Avoid tests that assert only “the failed event fired”; they do not establish business correctness.

How should you answer the common follow-ups?

Can I prevent duplicates with a unique job ID? A chosen job ID can constrain enqueue behavior under certain lifecycle conditions, but it does not turn processor execution into an exactly-once transaction. A retried or stalled job can still run again. Tie your idempotency guard to the business operation.

Should I increase maxStalledCount? First diagnose missed renewals and decide whether a second stall indicates a pathological job. A higher limit may be appropriate for a known transient environment, but it also delays permanent failure visibility. It does not protect external side effects.

Does concurrency: 50 mean fifty CPU tasks run in parallel? No. In one Node.js Worker instance, it chiefly permits overlap while asynchronous I/O waits. CPU-heavy synchronous work can block the event loop; use separate processes or sandboxed execution when that is the bottleneck.

Do I still need QueueScheduler? BullMQ's stalled-job documentation says that from BullMQ 2.0 onward it is not needed for normal operation. State the version you are discussing before repeating setup advice from older articles.

Sources and Further Reading