BullMQ: Job & Message Queues
BullMQ is a fast, robust message-queue library for Node.js, backed by Redis. It lets you push slow or unreliable work — sending email, resizing images, calling third-party APIs — out of the request path and process it in the background, with built-in retries, delays, scheduling, and concurrency control. Because the queue state lives in Redis, jobs survive process restarts and can be shared across many worker machines. This page covers creating queues, adding jobs, writing workers, delayed and repeatable jobs, retries, and concurrency.
Installing and connecting
BullMQ runs on any maintained Node.js release; Node 20 or 22 LTS is the sensible default. It requires a Redis server (version 6.2 or newer is recommended). Install the package from npm — it ships both ES module and CommonJS builds, so const { Queue } = require("bullmq") works unchanged.
npm install bullmq
Every BullMQ object takes a connection describing how to reach Redis. You can pass plain options or share a single ioredis instance across producers and workers to avoid opening redundant connections.
const connection = { host: "127.0.0.1", port: 6379 };
Creating a queue and adding jobs
A Queue is the producer side: the handle your application code uses to enqueue work. Each job has a name (used to route it to a handler) and a data payload, which must be JSON-serializable. Adding a job is non-blocking — it returns once the job is persisted to Redis, long before any worker touches it.
import { Queue } from "bullmq";
const connection = { host: "127.0.0.1", port: 6379 };
const emailQueue = new Queue("email", { connection });
const job = await emailQueue.add("welcome", {
to: "[email protected]",
template: "welcome",
});
console.log(`Queued job ${job.id} (${job.name})`);
Output:
Queued job 1 (welcome)
You can enqueue many jobs at once with addBulk, which is far more efficient than awaiting add in a loop.
await emailQueue.addBulk([
{ name: "welcome", data: { to: "[email protected]" } },
{ name: "welcome", data: { to: "[email protected]" } },
]);
Workers and processors
A Worker is the consumer side. It connects to the same queue name and runs a processor function for each job it pulls off the queue. The value the processor returns is stored as the job’s result; if it throws, the job is marked failed (and may be retried). Workers run independently of your web server — typically in a separate process — and you can scale them horizontally by starting more.
import { Worker } from "bullmq";
const connection = { host: "127.0.0.1", port: 6379 };
const worker = new Worker(
"email",
async (job) => {
console.log(`Processing ${job.name} for ${job.data.to}`);
await sendEmail(job.data); // your real work here
return { sent: true };
},
{ connection },
);
worker.on("completed", (job, result) =>
console.log(`Job ${job.id} done:`, result),
);
worker.on("failed", (job, err) =>
console.error(`Job ${job?.id} failed:`, err.message),
);
Output:
Processing welcome for [email protected]
Job 1 done: { sent: true }
Separate your producers from your workers. Run the
Queueinside your API server and theWorkerin a dedicated process so a burst of background work never starves request handling.
Delayed and repeatable jobs
To defer a job, pass a delay (in milliseconds) — BullMQ holds it until the delay elapses, then makes it available to a worker.
// Run roughly 30 seconds from now.
await emailQueue.add("reminder", { to: "[email protected]" }, { delay: 30_000 });
For recurring work, use a repeatable job (a job scheduler) with either a cron pattern or a fixed interval. BullMQ enqueues a fresh job on each tick.
// Every weekday at 09:00.
await emailQueue.add(
"daily-digest",
{ kind: "digest" },
{ repeat: { pattern: "0 9 * * 1-5" } },
);
// Or a fixed cadence: every 15 minutes.
await emailQueue.add(
"poll-inbox",
{},
{ repeat: { every: 15 * 60 * 1000 } },
);
Retries and backoff
Transient failures — a flaky API, a momentary network blip — should not lose a job. Set attempts to allow retries, and a backoff strategy to space them out. exponential doubles the wait after each failure, which is gentler on a struggling dependency than retrying immediately.
await emailQueue.add(
"welcome",
{ to: "[email protected]" },
{
attempts: 5,
backoff: { type: "exponential", delay: 1000 }, // 1s, 2s, 4s, 8s, ...
},
);
A job that exhausts every attempt lands in the failed set, where it is retained for inspection and can be retried manually. These per-job options are summarized below.
| Option | Type | Purpose |
|---|---|---|
delay | number (ms) | Wait before the job becomes available |
attempts | number | Total tries before the job is failed |
backoff | object | Retry spacing (fixed or exponential) |
repeat | object | Cron pattern or every interval |
priority | number | Lower value runs sooner |
removeOnComplete | boolean / number | Auto-clean finished jobs |
Concurrency
By default a worker processes one job at a time. Raise the concurrency option to let a single worker handle several jobs in parallel — ideal for I/O-bound work where each job spends most of its time awaiting the network. For CPU-bound work, prefer more worker processes (or a sandboxed processor) over high in-process concurrency.
const worker = new Worker("email", processor, {
connection,
concurrency: 10, // up to 10 jobs in flight at once
});
You can also throttle throughput with a limiter — for example, to respect a rate-limited upstream API.
const worker = new Worker("email", processor, {
connection,
limiter: { max: 100, duration: 1000 }, // at most 100 jobs/second
});
Best practices
- Run workers in their own process, separate from your HTTP server, and scale them independently.
- Keep job payloads small and JSON-serializable; store large blobs elsewhere and pass a reference (an ID or URL).
- Always set
attemptswith anexponentialbackoff so transient failures recover without manual intervention. - Use
removeOnCompleteandremoveOnFail(with a count or age) to stop Redis filling up with finished jobs. - Make processors idempotent — a job can run more than once after a crash or retry.
- Tune
concurrencyfor I/O-bound jobs; add worker processes for CPU-bound ones. - Listen for
failedanderrorevents and forward them to your logging/monitoring stack.