Skip to content
Node.js nd libraries 5 min read

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 Queue inside your API server and the Worker in 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.

OptionTypePurpose
delaynumber (ms)Wait before the job becomes available
attemptsnumberTotal tries before the job is failed
backoffobjectRetry spacing (fixed or exponential)
repeatobjectCron pattern or every interval
prioritynumberLower value runs sooner
removeOnCompleteboolean / numberAuto-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 attempts with an exponential backoff so transient failures recover without manual intervention.
  • Use removeOnComplete and removeOnFail (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 concurrency for I/O-bound jobs; add worker processes for CPU-bound ones.
  • Listen for failed and error events and forward them to your logging/monitoring stack.
Last updated June 14, 2026
Was this helpful?