Skip to main content
BullMQ 6 in NestJS: Stalled Jobs, Deduplication, and Moving Repeatable Jobs to Job Schedulers — Field Notes

BullMQ 6 in NestJS: Stalled Jobs, Deduplication, and Moving Repeatable Jobs to Job Schedulers — Field Notes

October 27, 2026
Backend Engineering
9 min read

BullMQ, the Redis-backed job queue for Node.js, fails in production mostly through configuration rather than code. A Redis maxmemory-policy other than noeviction lets Redis evict queue keys. A CPU-bound processor that blocks the event loop past its 30-second job lock gets the same job run twice. A custom jobId silently drops legitimate jobs. BullMQ 6.0.0, released on 2026-07-30, also removes the legacy repeat option, so recurring jobs have to move to Job Schedulers before the upgrade.

At Devya we run BullMQ behind NestJS APIs for the work that should not run inside an HTTP request: transactional emails, PDF reports, webhook fan-out and nightly cleanups. The happy path takes an afternoon to build; the failures took our team longer, because none of them threw an error where we were looking. These notes are written against BullMQ 6.5 and @nestjs/bullmq 12.

Key takeaways

Redis for BullMQ must run with maxmemory-policy noeviction. The BullMQ docs call it the only setting that guarantees correct queue behavior, and the policy applies to the whole Redis instance, not to one logical database.

A stalled job is a job whose lock expired while it was still running. BullMQ moves it back to waiting and another worker runs it again, so CPU-heavy processors belong in sandboxed processors.

BullMQ delivers jobs at least once, so every processor must be idempotent even when jobId or deduplication is set.

A custom jobId deduplicates against every job still stored in Redis, including completed jobs kept by removeOnComplete. The deduplication option only blocks duplicates while a job is unfinished or inside a TTL.

BullMQ 6 raises an error on legacy repeatable-job data in Redis, so we move every repeat job to upsertJobScheduler() while still on BullMQ 5.

How do we set up BullMQ 6 in a NestJS app?

A production BullMQ setup in NestJS is one BullModule.forRootAsync() for the Redis connection, one BullModule.registerQueue() per queue with default retry and retention options, and one processor class per queue that extends WorkerHost. BullMQ 6 made ioredis an optional peer dependency, so we install bullmq, @nestjs/bullmq and ioredis explicitly.

We put retry defaults on the queue, not on every add() call: 5 attempts, exponential backoff starting at 2 seconds, and jitter 0.5. Exponential backoff waits 2^(attempts - 1) × delay milliseconds before each retry, and jitter 0.5 randomizes each delay between half and all of that value, so a burst of failures does not retry in lockstep.

removeOnComplete and removeOnFail take an age in seconds and a count. We keep completed jobs for 24 hours or 5,000 jobs and failed jobs for 7 days. Without these options, BullMQ keeps every finished job in Redis forever.

The processor is a normal NestJS provider, so it can inject services. Throwing UnrecoverableError from bullmq moves a job straight to the failed set without spending its remaining attempts; it replaces Job#discard(), which BullMQ 6 removed. We use it for errors that will not fix themselves, such as an invalid recipient address.

Why does BullMQ lose jobs when Redis is also used as a cache?

BullMQ loses jobs when Redis is allowed to evict keys, because every job, lock and queue list is a Redis key that an eviction policy such as allkeys-lru treats as disposable cache. A job evicted from the wait list is never processed and never fails, so no alert fires. The BullMQ production guide states that noeviction is the only maxmemory-policy that guarantees correct queue behavior.

The Redis maxmemory-policy setting is instance-wide: logical databases selected with SELECT share it. A cache wants eviction and a queue must never evict, so our team runs them on separate Redis instances. Under noeviction, a full Redis rejects writes with an out-of-memory error, which turns silent job loss into a loud queue.add() failure and makes the removeOnComplete and removeOnFail settings the real memory budget. We also enable AOF persistence (appendonly yes) so a Redis restart does not drop queued jobs.

Worker connections need the ioredis option maxRetriesPerRequest: null; BullMQ sets it for workers by default and logs a warning when a shared ioredis instance overrides it. Producer connections are the opposite case: the BullMQ docs suggest enableOfflineQueue: false on Queue instances, so an HTTP request that enqueues a job fails fast while Redis is down instead of hanging.

Why did a BullMQ job run twice?

A BullMQ job runs twice when it stalls: the worker processing it did not renew its lock in time, so BullMQ assumed the worker had died and handed the job to another worker. Each active job holds a lock for lockDuration (default 30,000 ms), and the worker renews it every lockRenewTime (default 15,000 ms). Renewal runs on the Node.js event loop, so a processor that blocks the loop — a synchronous PDF render, an image resize, a huge JSON.parse() — misses the renewal while it is still working.

The stalled-job checker runs every stalledInterval (default 30,000 ms) and moves the job back to waiting; once a job has stalled more than maxStalledCount times (default 1), it goes to the failed set instead. That recovery is correct when a container is killed mid-job and wrong when the original worker is alive, because two copies of the job now run at once.

Raising lockDuration hides the symptom and delays recovery from real crashes by the same amount, so we move CPU-bound work off the main thread instead. A sandboxed processor is a processor file that BullMQ runs in a separate child process, or in a worker thread when useWorkerThreads: true is set. In NestJS we create a plain BullMQ Worker inside a provider, pass it the path of the compiled processor file, and call worker.close() in onModuleDestroy.

The trade-off is dependency injection: a sandboxed processor runs outside the NestJS container, so it builds its own database and storage clients. We keep sandboxed processors small and leave I/O-bound jobs on regular WorkerHost processors.

How do we stop BullMQ from enqueuing the same job twice?

BullMQ has two built-in ways to drop duplicate jobs — a custom jobId and the deduplication job option — and they differ in how long the duplicate check lasts.

Custom jobId: a duplicate is ignored while any job with that ID is still stored in Redis, including completed and failed jobs, and BullMQ emits a duplicated event. It fits one job per entity, ever.

deduplication with an id: a duplicate is ignored while the first job is waiting, delayed or active, and BullMQ emits a deduplicated event. It fits the rule "no second job while one is in flight".

deduplication with an id and a ttl: a duplicate is ignored while the TTL window is open. It fits throttling and debouncing bursts.

The custom jobId trap is retention. With removeOnComplete set to an age of 86,400 seconds, a completed job with the ID receipt-42 stays in Redis for a day, and every add() with the same ID during that day is ignored without throwing. Custom job IDs also cannot be digit-only strings such as "123"; BullMQ throws "Custom Id cannot be integers".

BullMQ 6 removed the old debounce option. A debounce is now a delayed job with a matching ttl and both extend and replace set to true, so a burst of product edits produces one search reindex 5 seconds after the last edit, carrying the latest data.

Neither mechanism makes processing exactly-once. Retries and stalls still run the same job again, which is why our email processors pass an idempotency key derived from job.id to the mail provider.

What breaks when upgrading from BullMQ 5 to BullMQ 6?

BullMQ 6.0.0 removes legacy repeatable jobs and raises an error when it finds their metadata in Redis, so repeatable jobs have to be migrated to Job Schedulers while the app still runs BullMQ 5. A Job Scheduler is a named, upsertable definition that produces jobs on a cron pattern or an every interval; calling upsertJobScheduler() again with the same ID updates the schedule instead of creating a second one.

A BullMQ 5 job added with repeat: { pattern: '0 3 * * *', utc: true } becomes queue.upsertJobScheduler('purge-expired-sessions', { pattern: '0 3 * * *', tz: 'UTC' }, { name: 'purge-expired-sessions' }). We call it from onApplicationBootstrap() in a NestJS service; because the upsert is idempotent, every replica can run it on boot.

Job Schedulers already exist in BullMQ 5, so our order is: ship the scheduler code, remove the legacy definitions with queue.getRepeatableJobs() and queue.removeRepeatableByKey(), and only then bump the version.

The other BullMQ 6 breaking changes are mechanical: repeat.utc becomes tz: 'UTC'; the debounce option and debounced event are replaced by deduplication and the deduplicated event; Job#discard() is replaced by UnrecoverableError; Worker#resume() and Queue#resume() must be awaited; ioredis must be installed explicitly; and Queue#client and Worker#blockingClient are replaced by the backend returned by getBackend().

BullMQ 6 also introduces pluggable queue backends, including an optional PostgreSQL backend that runs the same Queue and Worker API on PostgreSQL 13 or newer through the createPostgresBackend factory. The BullMQ docs still describe Redis as the default and most battle-tested backend, and we have not moved a production queue to PostgreSQL yet.

FAQ

Q: Does BullMQ guarantee exactly-once job processing?

A: No. BullMQ delivers jobs at least once: a retry, a stalled job or a worker crash mid-job runs the processor again. Make every processor idempotent, for example with an idempotency key derived from job.id.

Q: Can BullMQ share a Redis instance with an application cache?

A: Not safely if the cache relies on an eviction policy such as allkeys-lru. The Redis maxmemory-policy setting applies to the whole instance, and BullMQ needs noeviction, so the queue should get its own Redis instance.

Q: How many finished jobs should removeOnComplete keep?

A: Keep enough completed jobs to debug recent work, bounded by both age and count — we use 24 hours and 5,000 jobs — and keep failed jobs longer. Leaving both options unset keeps every finished job in Redis forever.

Q: When should we increase lockDuration in BullMQ?

A: Rarely. A longer lockDuration also delays recovery of jobs from crashed workers by the same amount, so first move CPU-bound work into a sandboxed processor and keep the event loop free to renew locks.