mrkeyoor.com_
Wed 05 Aug 05:06 UTC
npmInfraupdated 05 Aug 2026

bullmq

BullMQ is a Redis-backed job queue for Node.js: you add jobs to named queues and worker processes pick them up, with retries, delays, priorities, rate limiting, cron-style schedulers, and parent-child job flows. Operations run as atomic Lua scripts, which is why it survives crashes and concurrent workers without double-processing. It is the maintained successor to Bull, run by Taskforce.sh, with official ports for Python, Rust, Elixir, and PHP, and the v6 release (July 30, 2026) introduced pluggable backends beyond a single Redis client.

Verdict

The default job queue for Node when Redis is on the table: atomic, fast, and actively developed with commercial backing. Accept the cost of running Redis and the Pro paywall on some features, and pick v5 or v6 deliberately since v6 is brand new.

API stability4/5The core Queue/Worker/FlowProducer API has carried over from v4 through v6, but majors arrive fast: v6 (July 2026) moved Redis clients to optional peer dependencies, and semantic-release means constant version churn.
Docs4/5docs.bullmq.io covers concepts, patterns, and an API reference, but open-source and Pro features are documented side by side, and some answers only exist in GitHub issues or the Taskforce blog.
Maintenance5/5Commercially backed by Taskforce.sh, pushed the same day as this review (August 2026), 9.2k stars; the paid Pro tier funds full-time work on the open-source core.
Ecosystem4/57.7M weekly downloads, official NestJS integration, bull-board and Taskforce UIs, Dragonfly compatibility, and ports to four other languages; smaller than Sidekiq-style incumbents elsewhere but the standard for Node.

Use it if

  • You already run Redis (or Valkey/Dragonfly) and need background jobs with retries, backoff, and scheduled or repeating work
  • You need orchestration: parent jobs that wait on children (flows), queue-level rate limits, per-worker concurrency
  • You use NestJS, where the official @nestjs/bullmq integration makes this the default queue
  • You are on the old Bull package and want the maintained successor with the same mental model
Skip it if

Setup reality

npm install bullmq, plus a Redis 6.2+ instance you run yourself, so docker compose becomes part of local dev. Since v6 the Redis client is an optional peer dependency: you install ioredis, redis v5+, or @valkey/valkey-glide and hand BullMQ the connection. The two classic footguns: ioredis connections for workers need maxRetriesPerRequest: null, and completed or failed jobs stay in Redis forever unless you set removeOnComplete and removeOnFail. Types are good, but the config surface (job options, worker options, events) takes a day to internalize.

Patterns

Add a job and process itadd-and-process

import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';

const connection = new IORedis({ maxRetriesPerRequest: null });

const queue = new Queue('emails', { connection });
await queue.add('welcome', { to: 'user@example.com' });

const worker = new Worker('emails', async (job) => {
  await sendEmail(job.data.to);
}, { connection });

Since v6 you install the Redis client yourself (ioredis here); worker connections need maxRetriesPerRequest: null or BullMQ rejects them.

Retry failed jobs with backoffretry-backoff

await queue.add('charge', { orderId: 42 }, {
  attempts: 5,
  backoff: { type: 'exponential', delay: 1000 }
});

Jobs only retry when attempts > 1; after the last attempt the job lands in the failed set and sits there until you act or a cleanup policy removes it.

Schedule a job for laterdelayed-job

await queue.add('reminder', { userId: 7 }, { delay: 60_000 });

Delay counts from add time. If no worker is running when the delay expires, the job just waits for the next worker to come up.

Run a job on a cron schedulerepeat-cron

await queue.upsertJobScheduler(
  'nightly-report',
  { pattern: '0 3 * * *' },
  { name: 'report', data: { scope: 'daily' } }
);

Job Schedulers replaced the older repeatable-jobs API; upserting by a fixed id means redeploys do not create duplicate schedules.

Listen for completion globallyqueue-events

import { QueueEvents } from 'bullmq';

const events = new QueueEvents('emails', { connection });
events.on('completed', ({ jobId }) => console.log('done', jobId));
events.on('failed', ({ jobId, failedReason }) => console.error(jobId, failedReason));

QueueEvents uses Redis streams on its own blocking connection; worker.on('completed') only sees jobs handled by that one worker process.

Run a parent job after its childrenflow-parent-child

import { FlowProducer } from 'bullmq';

const flow = new FlowProducer({ connection });
await flow.add({
  name: 'assemble-video',
  queueName: 'renders',
  children: [
    { name: 'transcode', data: { part: 1 }, queueName: 'transcode' },
    { name: 'transcode', data: { part: 2 }, queueName: 'transcode' }
  ]
});

The parent waits in a waiting-children state until every child completes; read child results in the parent via job.getChildrenValues().

Rate limit a queuerate-limit

const worker = new Worker('api-calls', processor, {
  connection,
  limiter: { max: 10, duration: 1000 }
});

The limit applies to the queue as a whole, not per worker instance; per-group rate limiting is a BullMQ Pro feature.

Process many jobs in parallelconcurrency

const worker = new Worker('jobs', processor, {
  connection,
  concurrency: 50
});

Concurrency shares one event loop, so it helps I/O-bound jobs only; CPU-bound work needs sandboxed processors or more worker processes.

Isolate heavy jobs in a child processsandboxed-processor

// worker.js
import { Worker } from 'bullmq';
const worker = new Worker(
  'renders',
  new URL('./processor.js', import.meta.url),
  { connection }
);

// processor.js
export default async function (job) {
  return heavyRender(job.data);
}

Passing a file path instead of a function runs jobs in a child process, so a crash or blocking loop cannot take down the worker; the job object inside is a proxy with a reduced API.

Stop finished jobs from filling Rediscleanup-policy

await queue.add('log-event', data, {
  removeOnComplete: { age: 3600, count: 1000 },
  removeOnFail: { age: 24 * 3600 }
});

Without these options finished jobs accumulate in Redis indefinitely; set them per add or once via the queue defaultJobOptions.

Shut a worker down cleanlygraceful-shutdown

process.on('SIGTERM', async () => {
  await worker.close();
  await queue.close();
  process.exit(0);
});

worker.close() waits for active jobs to finish; kill the process instead and those jobs get marked stalled and rerun after the stalled interval.

Deduplicate identical jobsdeduplicate

await queue.add('sync-user', { userId: 7 }, {
  deduplication: { id: 'sync-user-7', ttl: 5000 }
});

While a job with the same deduplication id exists, further adds are ignored; with a ttl it throttles, without one it holds until the job finishes.

Alternatives

PackageRegistryPick it when
pg-bossnpmYou already run Postgres and would rather not add Redis; SKIP LOCKED queues cover most job workloads
bee-queuenpmYou want a minimal, fast Redis queue and can live without repeatable jobs and flows
agendanpmMongoDB is your only datastore and job volume is modest; check its maintenance activity first