BullMQ Worker Not Processing Jobs? Fix It (2026)
August 3, 2026

A BullMQ worker that never picks up jobs usually fails one of four checks: it is not connected to Redis, it points at a different queue name or prefix than the producer, the queue is paused or rate limited, or jobs are stalling. Check them in that order.
TL;DR: Start by proving the worker is alive and connected, then prove it shares the producer's exact queue name and prefix, then check isPaused() and getJobCounts() before you touch any timing options. Only after those come the stalled-job settings — lockDuration and stalledInterval both default to 30,000 ms in bullmq@6.0.5.1 If the symptom started right after an upgrade, BullMQ v6 shipped on 30 July 2026 and moved ioredis to an optional peer dependency, so npm install bullmq@6 on its own installs no Redis client at all.23
What you'll learn
- The ordered checklist that isolates the cause in four steps instead of guessing
- Why jobs sit in
waitingwhile workers look perfectly healthy - How a queue name or
prefixmismatch silently splits producer from consumer - What
maxRetriesPerRequest must be nullactually means, and why it throws in one case and only warns in another - Why a missing
errorlistener can stop a worker dead - The two failure modes introduced by BullMQ v6: the
ioredispeer dependency and legacy repeatable-job metadata - What stalled jobs really are, and which defaults to change (and which to leave alone)
- Whether a paused queue, a rate limiter, or global concurrency is throttling you
- Whether you still need
QueueSchedulerfor delayed jobs
Why is my BullMQ worker not processing jobs?
Because the worker and the jobs are not meeting. That happens in one of four places, and checking them in order is faster than reading your processor code: connection (the worker never reached Redis), identity (queue name or prefix differs from the producer's), state (the queue is paused, rate limited, or concurrency-capped), and lifecycle (jobs enter active, lose their lock, and get pushed back to waiting).
The reason ordering matters is that the stalled-job path — the category most troubleshooting advice reaches for first — is the only one of the four that requires a job to have reached active at all. If your active count is flat at zero, no amount of lockDuration tuning will help you, because nothing is ever acquiring a lock in the first place.
How do I debug a BullMQ worker that is not picking up jobs?
Ask the queue what it thinks is happening before you change any code. These four calls answer the "connection, identity, state" questions in one pass:
import { Queue } from 'bullmq';
const queue = new Queue('emails', { connection, prefix: 'bull' });
console.log('paused:', await queue.isPaused());
console.log('counts:', await queue.getJobCounts());
console.log('workers:', await queue.getWorkersCount());
console.log('rate-limit ttl:', await queue.getRateLimitTtl(100));
Read the output like this:
| Observation | What it points to |
|---|---|
getWorkersCount() is 0 | The worker is not connected, or is registered under a different name/prefix |
paused: true | The queue is globally paused — nothing will be picked up4 |
waiting climbing, active stays 0 | Identity mismatch, pause, rate limit, or global concurrency |
active non-zero but jobs never finish | Stalled path: locks, event-loop blocking, or a crashing processor |
rate-limit ttl greater than 0 | The limiter is holding jobs in waiting5 |
getWorkersCount() returning zero while your process is clearly running is the highest-yield line here, because it collapses "is it connected?" and "is it the same queue?" into one answer. Called with no arguments, getJobCounts() reports active, completed, delayed, failed, prioritized, waiting and waiting-children — the default set in bullmq@6.0.5.1
One caveat on that first line, because it changes how much you should trust it. getWorkersCount() is built on Redis's CLIENT LIST, and BullMQ's own type definitions warn that "GCP does not support SETNAME, so this call will not work." On a managed Redis that rejects the CLIENT command, BullMQ catches the error and substitutes a single placeholder entry, so the count comes back as 1 whether or not a worker is actually attached.1 If you are on Memorystore or a similarly locked-down provider, treat a count of exactly 1 as "unknown" rather than as proof, and fall back to checking whether active ever moves.
Why are my BullMQ jobs stuck in the waiting state?
waiting is where jobs pile up for several different reasons, which is why it is such a confusing symptom. A rate-limited job is not a broken job — BullMQ's docs are explicit that "jobs that get rate limited will actually stay in the waiting state."5 A stalled job is also returned to waiting, not to a state of its own.6
So waiting growing tells you a job has not been claimed yet. It does not tell you why. The three benign explanations are a paused queue, an active rate-limit window, and a global concurrency cap that is already saturated. The two broken explanations are no connected worker and a queue-identity mismatch. Distinguish them with getWorkersCount() and isPaused() before assuming a bug.
One more thing worth knowing about the limiter: it is global across your fleet, not per-process. The docs put it plainly — with max: 10, duration: 1000, "if you have for example 10 workers for one queue with the above settings, still only 10 jobs will be processed by second."5 Scaling out worker replicas will not move a rate-limited queue any faster.
Do the queue and the worker need the same name and prefix?
Yes — exactly the same, on both. A Queue and a Worker only find each other through the Redis keys they compute, and those keys are derived from the queue name plus the prefix option, which "defaults to bull" in bullmq@6.0.5.1 If the producer sets prefix: 'myapp' and the worker leaves it unset, they are operating on two entirely separate key spaces, and neither will report an error.
// Producer and consumer must agree on BOTH arguments.
const queue = new Queue('emails', { connection, prefix: 'myapp' });
const worker = new Worker('emails', handler, { connection, prefix: 'myapp' });
This is a common casualty of environment-variable drift: one service reads QUEUE_PREFIX and another was deployed before that variable existed. BullMQ's troubleshooting page flags the related hazard of passing undefined or empty-string values into queue names, which surfaces as an opaque Lua error rather than a clear message: ERR Error running script ... Lua redis() command arguments must be strings or integers.7 Validate those variables at startup rather than letting them reach Redis.
Note that prefix is a Redis-specific concept in v6. The shipped type definitions explain that it is deliberately not part of the shared queue options "because it is a Redis-specific concept: other backends namespace differently (e.g. the PostgreSQL backend uses a schema) and ignore it."1
Why does BullMQ throw "maxRetriesPerRequest must be null"?
Because a worker holds a blocking connection to Redis, and if ioredis is allowed to give up on a command after a fixed number of retries, that blocking call can fail and take the worker's fetch loop down with it. BullMQ's guidance is that for workers this option must be null so that "the workers will keep processing forever as long as there is a working connection."8
What is not obvious — and what changes how you debug this — is that BullMQ reacts in two different ways depending on how you pass the connection. Running both cases against bullmq@6.0.5 with ioredis@5.11.1 produces:
| How you pass the connection | Result |
|---|---|
An existing ioredis instance with a non-null maxRetriesPerRequest | Constructor throws: BullMQ: Your redis options maxRetriesPerRequest must be null. |
A plain options object with maxRetriesPerRequest: 20 | Logs BullMQ: WARNING! Your redis options maxRetriesPerRequest must be null and will be overridden by BullMQ. and continues, with BullMQ setting it to null |
The second row is the one to watch. Because it only warns, the message can be lost in a container log while the value you set is silently replaced — so if you deliberately configured a retry ceiling for a worker, BullMQ has already discarded it. ioredis defaults this setting to 20 when you construct a client yourself,8 so a hand-rolled shared client will hit one of these two paths. Two limits are worth knowing: the check only fires for blocking connections, so the same client handed to a Queue triggers neither branch, and it is a truthiness test, so maxRetriesPerRequest: 0 also slips past both. Worth noting too that the production guide describes both cases as producing a warning;9 the throw above is what bullmq@6.0.5 actually does when handed a client instance.
import IORedis from 'ioredis';
// Correct for a Worker: no retry ceiling on the blocking connection.
const connection = new IORedis({ maxRetriesPerRequest: null });
const worker = new Worker('emails', handler, { connection });
While you are in that constructor: do not set ioredis's keyPrefix. Passing an instance built with keyPrefix throws BullMQ: ioredis does not support ioredis prefixes, use the prefix option instead. — verified against the same version pair. Use BullMQ's own prefix option instead, as covered above.
A Queue used by a request handler wants the opposite behaviour. The docs recommend leaving maxRetriesPerRequest at its default (or setting it to 1) for producers so an HTTP caller fails fast,8 and — on the production guide — disabling ioredis's enableOfflineQueue on the Queue while leaving it enabled on the Worker.9
Why did my BullMQ worker stop processing jobs after an error?
Because an EventEmitter that emits error with no listener attached raises in Node.js, and BullMQ's own documentation carries this as a danger notice: "If the error handler is missing, your worker may stop processing jobs when an error is emitted!"10 It is a two-line fix, and an easy one to omit, because nothing in the happy path requires it.
worker.on('error', err => {
logger.error({ err }, 'bullmq worker error');
});
queue.on('error', err => {
logger.error({ err }, 'bullmq queue error');
});
The related gotcha is autorun. A worker "launches the processor immediately" when constructed, unless you pass autorun: false — in which case nothing is processed until you call worker.run() yourself.10 The default is true in bullmq@6.0.5,1 so this only bites people who set the option deliberately in tests or in a staged-startup sequence and then forgot the matching run().
Do I need to install ioredis separately for BullMQ v6?
Yes, if you want the ioredis backend — and this changed in v6. BullMQ v5 listed ioredis as a hard dependency (pinned at 5.11.1 in 5.81.3). BullMQ v6.0.5 does not list it at all; ioredis, redis, pg and bullmq-otel are declared as peer dependencies marked optional.2 Optional peer dependencies are not auto-installed, so a clean install produces a package tree with no Redis client in it.
Installing bullmq@6.0.5 on its own into an empty project (npm 10.9.8, Node 22.22.3) produces eight top-level entries in node_modules — bullmq and its direct dependencies, plus the luxon and msgpackr-extract packages those pull in. None of them is a Redis client. Requiring the package then fails immediately:
Error: Cannot find module 'ioredis/built/utils'
The fix is one line:
npm install bullmq ioredis
This is a consequence of the headline v6 change. The release is described as "release BullMQ v6 with pluggable queue backends,"3 and the connection layer now supports node-redis and Bun's Redis client alongside ioredis, with the production guide giving separate reconnect advice for each.9 Making every client optional is what lets you install only the one you use. It also means an upgrade that looks like a patch bump to a lockfile can leave a service with no Redis driver.
Why did my worker break after upgrading to BullMQ v6?
Beyond the missing client, the other upgrade trap is repeatable jobs. BullMQ v6 removed the legacy repeatable-job API entirely, and the migration guide is blunt about what happens if old data is left behind: "Legacy repeatable jobs stored by BullMQ v5 are not supported in BullMQ v6. If v6 encounters legacy repeatable-job metadata, it raises an error instead of trying to keep running with partially compatible behavior."11
The message reads:
Legacy repeatable job metadata is not supported in BullMQ v6 (key: "..."). Migrate legacy repeatable jobs to Job Schedulers before upgrading. See https://docs.bullmq.io/guide/migrations/migrate-from-v5-to-v6
Where you see it depends on what touched the metadata. Listing schedulers with queue.getJobSchedulers() raises it directly. A worker that finishes a job carrying a legacy repeat key takes a different route: it cannot schedule the next iteration, so it emits the message on its own error event, wrapped as Failed to add repeatable job for next iteration: <message>.1 That detail matters here — if you skipped the error listener from the previous section, this arrives silently, and all you observe is that a recurring job quietly stopped recurring.
These v5 APIs are gone in v6: Queue.add(..., { repeat }), Queue.addBulk(..., { repeat }), Queue.getRepeatableJobs(), Queue.removeRepeatable(), Queue.removeRepeatableByKey(), and the Repeat class. The replacements are queue.upsertJobScheduler(...), queue.getJobSchedulers() and queue.removeJobScheduler(...).11
The migration is meant to happen while you are still on v5: enumerate the old definitions with getRepeatableJobs(), recreate each one as a Job Scheduler, verify with getJobSchedulers(), then delete the legacy entries — and only deploy v6 once every producer and worker is on schedulers.11 Two smaller breaks are worth grepping for at the same time: repeat.utc is replaced by upsertJobScheduler(..., { tz: 'UTC' }), and Worker.resume() is now asynchronous — its signature changed from resume(): void in 5.81.3 to resume(): Promise<void> in 6.0.5, so an un-awaited call now leaves a floating promise. Note that the migration guide files this change under the heading "Queue.resume() is asynchronous," but Queue.resume() already returned Promise<void> in v5; the type definitions in both packages show the change is on Worker.111
What are stalled jobs in BullMQ and how do I stop them?
A stalled job is one that reached active, then failed to renew its lock in time, so BullMQ assumes the worker died and returns the job to waiting. There is no stalled state — the docs note that there is "only a 'stalled' event emitted when a job is automatically moved from active to waiting state."6 If a job stalls more than maxStalledCount times it is failed permanently with the reason job stalled more than allowable limit.6
The defaults, read from the type definitions shipped in bullmq@6.0.5:1
| Option | Default | What it controls |
|---|---|---|
lockDuration | 30000 ms | How long a worker's claim on a job survives without renewal |
lockRenewTime | half lockDuration | Renewal cadence; the docs advise against changing it |
stalledInterval | 30000 ms | How often the stalled check runs |
maxStalledCount | 1 | Recoveries from stalled before the job is failed |
concurrency | 1 | Jobs one worker instance runs in parallel |
drainDelay | 5 (seconds) | Long-poll window when the queue is empty |
maximumRateLimitDelay | 30000 ms | Idle ceiling while rate limited |
The important framing is that a stalled job is usually a symptom of blocked code, not of a badly tuned lock. Node.js is single-threaded, and BullMQ's guidance is that "if the CPU is very busy (due to the process being very CPU intensive), the worker may not have time to renew the lock."12 Raising lockDuration on a processor that blocks the event loop for a minute buys you a longer silence, not a fix. The two real fixes are returning control to the event loop more often, and moving genuinely CPU-bound work into a sandboxed processor, which by default runs the job in a separate Node.js process (useWorkerThreads switches it to a worker thread instead).61
Four non-obvious causes of lock loss are worth checking before you touch any numbers, all listed on BullMQ's troubleshooting page under "Missing Locks": CPU starvation, lost communication with Redis, the job being force-removed by one of the removal APIs, and a wrong Redis maxmemory policy.7 That last one deserves its own emphasis — the production guide states that configuring maxmemory-policy to noeviction "is the only setting that guarantees the correct behavior of the queues."9 A Redis instance provisioned from a cache template will happily evict BullMQ's keys under memory pressure.
Deploys are the other routine source of stalls. A worker killed mid-job leaves that job to be recovered by the stalled check "with a waiting time of about 30 seconds by default," which is why the production guide recommends handling SIGINT and SIGTERM and awaiting worker.close() before exit.9
Is my queue paused, rate limited, or concurrency capped?
All three look identical from the outside — jobs accumulate in waiting and workers sit idle — and all three are answerable with one call each.
Paused. A globally paused queue means "no workers will pick up any jobs from the queue."4 Workers already mid-job finish that job and then idle. Check await queue.isPaused() and resume with await queue.resume(). A worker-level pause is a separate mechanism: the docs describe myWorker.pause() as stopping that instance from taking new jobs while it finishes its current ones,4 and because it is scoped to the instance rather than to the queue's shared state, it will not make queue.isPaused() return true.
Rate limited. await queue.getRateLimitTtl(maxJobs) returns a value greater than zero while the limiter window is open, and await queue.removeRateLimitKey() clears it and lets workers pick jobs again.5 Remember the limiter is global, and that group-key rate limiting was removed in BullMQ 3.0.5
Concurrency capped. setGlobalConcurrency(n) caps parallel processing across every worker instance, and per-worker concurrency cannot exceed it — the docs note that a worker's own setting "will not override the global one."13 Read the current value with await queue.getGlobalConcurrency() and clear it with await queue.removeGlobalConcurrency().13
Do I still need QueueScheduler for delayed jobs?
No. QueueScheduler was required before BullMQ 2.0 for delayed and retried jobs to be promoted, and its absence was once a genuine cause of jobs never running. The current docs repeat the same line in three places: "From BullMQ 2.0 and onwards, the QueueScheduler is not needed anymore."6145
This matters for diagnosis because guidance written against BullMQ 1.x instructs you to add a QueueScheduler, and that advice is obsolete on any currently supported version. If delayed jobs are not firing on v5 or v6, look at the causes above instead, particularly the connection and identity checks.
One genuine version floor does exist, though. bullmq@6.0.5 refuses to run against Redis older than 5.0.0, throwing Redis version needs to be greater or equal than 5.0.0, and logs a recommendation to run at least 6.2.0.1 Some blocking-call optimisations are additionally gated on Redis 6.0.0 and 7.0.8.1
Bottom line
Resist the urge to start with lockDuration. The fastest path through this problem is to prove connection, then identity, then state, and only then look at the stalled-job path — because three of those four produce the same "worker idle, jobs waiting" symptom and only the last one is a timing problem. Since the v6 release on 30 July 2026 there is one extra step: if the symptom appeared right after a dependency bump, check whether you landed on v6 and lost your Redis client along the way.
If you are re-evaluating the queue itself rather than debugging it, it is worth knowing what the alternatives cost. A Postgres-backed job queue with pg-boss removes Redis from the picture entirely, NATS JetStream with durable workers and a DLQ trades it for a different operational model, and the broader Kafka vs RabbitMQ vs SQS vs NATS comparison covers where each one fits. If you are staying on Redis, the same noeviction and connection-reuse concerns show up across Redis caching patterns too.
Footnotes
-
Type definitions and compiled source shipped in
bullmq@6.0.5, installed and read on 2026-08-03 —interfaces/worker-options.d.ts(@defaultValuetags forautorun,concurrency,lockDuration,stalledInterval,maxStalledCount,drainDelay,maximumRateLimitDelayanduseWorkerThreads;lockRenewTime's "half the lockDuration" default is stated in prose rather than in a tag),interfaces/queue-options.d.ts(KeyPrefixOptions.prefix, "Defaults tobull"),classes/queue-getters.jsand.d.ts(thesanitizeJobTypesdefault type list;baseGetClientssubstituting a single placeholder entry when Redis rejects theCLIENTcommand; the "GCP does not support SETNAME, so this call will not work" note ongetWorkers),classes/worker.js(theFailed to add repeatable job for next iteration:wrapper emitted on the worker'serrorevent) andclasses/redis-connection.js(minimumVersion = '5.0.0',recommendedMinimumVersion = '6.2.0', capability gates at 6.0.0 and 7.0.8).Worker.resume()compared across the installedbullmq@5.81.3(resume(): void) andbullmq@6.0.5(resume(): Promise<void>). https://www.npmjs.com/package/bullmq ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 -
npm registry metadata for
bullmq, read 2026-08-03:6.0.5listspg,redis,ioredisandbullmq-otelunderpeerDependencieswith all four markedoptional: trueinpeerDependenciesMeta, and does not listioredisunderdependencies;5.81.3listsioredis: 5.11.1as a direct dependency. https://registry.npmjs.org/bullmq ↩ ↩2 ↩3 -
BullMQ v6.0.0 release, published 30 July 2026: "feat!: release BullMQ v6 with pluggable queue backends." https://github.com/taskforcesh/bullmq/releases/tag/v6.0.0 ↩ ↩2
-
BullMQ documentation — Pausing queues. https://docs.bullmq.io/guide/workers/pausing-queues ↩ ↩2 ↩3 ↩4
-
BullMQ documentation — Rate limiting. https://docs.bullmq.io/guide/rate-limiting ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
BullMQ documentation — Stalled (jobs). https://docs.bullmq.io/guide/jobs/stalled ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
BullMQ documentation — Troubleshooting. https://docs.bullmq.io/guide/troubleshooting ↩ ↩2 ↩3
-
BullMQ documentation — Connections. https://docs.bullmq.io/guide/connections ↩ ↩2 ↩3 ↩4
-
BullMQ documentation — Going to production. https://docs.bullmq.io/guide/going-to-production ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
BullMQ documentation — Workers. https://docs.bullmq.io/guide/workers ↩ ↩2 ↩3
-
BullMQ documentation — Migrate from v5 to v6. https://docs.bullmq.io/guide/migrations/migrate-from-v5-to-v6 ↩ ↩2 ↩3 ↩4 ↩5
-
BullMQ documentation — Stalled Jobs (workers). https://docs.bullmq.io/guide/workers/stalled-jobs ↩ ↩2
-
BullMQ documentation — Global Concurrency. https://docs.bullmq.io/guide/queues/global-concurrency ↩ ↩2 ↩3
-
BullMQ documentation — Queues. https://docs.bullmq.io/guide/queues ↩