How to Set Up BullMQ Workers: Concurrency, Patterns & Error Handling for Production
Introduction
Background jobs are the backbone of any non-trivial Node.js application. Whether you're sending emails, processing image uploads, syncing data with third-party APIs, or generating PDF reports — the pattern is the same: enqueue work as a job and let a worker process it asynchronously. BullMQ, built on top of Redis, is the most popular queue library in the Node.js ecosystem for good reason: it's fast, feature-rich, and battle-tested at scale.
But here's the problem — a misconfigured worker silently drops work, stalls jobs under load, or crashes your entire process when an unexpected error surfaces. We've seen production incidents where a single unhandled rejection in a worker processor brought down a dozen microservices, and cases where "stalled" jobs piled up because concurrency was set too high for the event loop to keep up.
This post walks through everything you need to configure BullMQ Workers correctly for production. We'll cover Worker fundamentals, concurrency control, error handling and retry strategies, event-driven observability, graceful shutdown patterns, and sandbox workers for CPU-intensive tasks. By the end, you'll know how to build workers that are resilient, observable, and ready for production traffic.
BullMQ Worker Fundamentals
A BullMQ Worker pulls jobs from a Redis-backed queue and executes them via a processor function you provide. It's the consumer side of the queue equation — the counterpart to the Queue class that adds jobs.
Here's the simplest possible Worker:
import { Worker } from "bullmq";
const worker = new Worker("email", async (job) => {
await sendEmail(job.data.to, job.data.subject, job.data.body);
return { sent: true, recipient: job.data.to };
});
When the processor completes successfully, the job moves to the completed state and the return value is stored as job.returnvalue. If the processor throws an error, the job moves to the failed state and is optionally retried depending on your configuration.
BullMQ is fully typed, so you can leverage TypeScript generics to enforce job data and return types:
interface EmailJobData {
to: string;
subject: string;
body: string;
}
interface EmailJobReturn {
sent: boolean;
messageId: string;
}
const worker = new Worker<EmailJobData, EmailJobReturn>(
"email",
async (job) => {
const info = await sendMail(job.data);
return { sent: true, messageId: info.messageId };
}
);
The processor function receives three arguments:
| Parameter | Description |
|---|---|
job |
The Job instance — access job.data, job.id, job.name, job.progress(), etc. |
token |
A unique worker token used internally for job locking (rarely needed in processors). |
signal |
An AbortSignal that fires when the job is cancelled — useful for cancelling fetch requests or database queries. |
A common best practice is to create workers with autorun: false so you can attach event listeners before the worker starts processing jobs:
const worker = new Worker("email", processor, { autorun: false });
worker.on("completed", (job) => {
console.log(`Job ${job.id} completed`);
});
worker.on("failed", (job, err) => {
console.error(`Job ${job.id} failed: ${err.message}`);
});
await worker.run(); // now start processing
This avoids a race condition where the first job completes before your completed listener is registered.
Redis Connection Setup
By default, each BullMQ class (Worker, Queue, QueueEvents, etc.) creates its own Redis connection pointing to localhost:6379. That's fine for local development but wasteful and potentially dangerous in production — too many connections can exhaust Redis's maxclients limit.
The recommended approach is to create a single IORedis connection and share it across all BullMQ instances. When you do this, you MUST set maxRetryPerRequest: null to tell BullMQ not to use IORedis's built-in retry mechanism (BullMQ handles retries itself):
import IORedis from "ioredis";
import { Worker, Queue } from "bullmq";
const connection = new IORedis({
host: "my-redis.example.com",
port: 6379,
maxRetriesPerRequest: null,
});
const queue = new Queue("email", { connection });
const worker = new Worker("email", processor, { connection });
For production deployments on AWS ElastiCache, Google Memorystore, or self-hosted Redis with TLS, you'll need to configure the connection accordingly:
const connection = new IORedis({
host: "my-redis-cluster.xxxxxx.0001.use1.cache.amazonaws.com",
port: 6379,
tls: {},
maxRetriesPerRequest: null,
connectTimeout: 10000,
retryStrategy: (times) => Math.min(times * 50, 2000),
});
The tls: {} option enables TLS without requiring client certificates. If your setup uses Valkey (the Redis-compatible fork) or requires client cert authentication, pass the cert material in the tls object:
const connection = new IORedis({
host: "my-valkey.example.com",
port: 6379,
tls: {
key: fs.readFileSync("./client-key.pem"),
cert: fs.readFileSync("./client-cert.pem"),
ca: [fs.readFileSync("./ca.pem")],
},
maxRetriesPerRequest: null,
});
Never skip maxRetriesPerRequest: null when sharing a connection — without it, IORedis will retry failed requests internally, which conflicts with BullMQ's own retry logic and can cause duplicate job processing or missed job states.
Concurrency Control
One of the most impactful Worker configuration options is concurrency. This controls how many jobs a single Worker processes simultaneously.
const worker = new Worker("paint", processor, {
concurrency: 10,
});
With concurrency: 10, the Worker picks up to 10 jobs at once and runs their processors concurrently. This is perfect for I/O-bound work like HTTP requests, database queries, or file reads where the event loop would otherwise be idle between operations.
Choosing the Right Concurrency
There's no universal "right" value — it depends on your workload:
- I/O-bound tasks (API calls, email sends): 10–50 is common. The event loop handles concurrent I/O efficiently.
- CPU-light tasks (JSON parsing, light transforms): 4–8 keeps cores busy without overwhelming.
- CPU-heavy tasks: Don't use concurrency — use sandbox workers instead (see below).
You can also adjust concurrency at runtime using the concurrency setter:
// Start conservative, increase after warm-up
worker.concurrency = 2;
// Later when healthy
worker.concurrency = 20;
Multiple Worker Instances
For horizontal scaling, run multiple Worker instances on the same queue — either in the same process or across different machines:
// Two workers on the same queue = double throughput
const worker1 = new Worker("email", processor, { concurrency: 5 });
const worker2 = new Worker("email", processor, { concurrency: 5 });
// Total capacity: 10 concurrent jobs
BullMQ uses Redis-backed job locks to ensure each job is processed by exactly one worker.
Rate Limiting
Sometimes concurrency alone isn't enough. If your workers call an external API with rate limits, you need a limiter:
const worker = new Worker("api-calls", processor, {
limiter: {
max: 10, // 10 jobs
duration: 1000, // per second
},
});
This ensures the Worker processes at most 10 jobs per second, regardless of concurrency. BullMQ implements this using Redis sorted sets — it's accurate and doesn't drift over time.
Even better is group rate limiting, where you apply separate limits per tenant or customer:
const worker = new Worker("api-calls", processor, {
groupLimiter: {
max: 5,
duration: 1000,
groupKey: (job) => job.data.tenantId,
},
});
Group rate limiting is essential for multi-tenant SaaS applications where one customer's workload shouldn't starve another's.
Job Processing Patterns
Single and Batch Jobs
The simplest pattern is adding individual jobs, but you can also add batches for efficiency:
const queue = new Queue("notifications", { connection });
// Single job
await queue.add("welcome-email", { userId: 42 });
// Batch jobs (single Redis round-trip)
await queue.addBulk([
{ name: "welcome-email", data: { userId: 1 } },
{ name: "welcome-email", data: { userId: 2 } },
{ name: "welcome-email", data: { userId: 3 } },
]);
Job Deduplication (BullMQ v5+)
BullMQ v5 introduced built-in deduplication — useful for webhook handlers where the same event might arrive twice:
await queue.add("webhook-event", payload, {
deduplication: {
id: "stripe-invoice-12345",
ttl: 86400000, // dedupe window: 24 hours
},
});
Job Name Routing
Use the job name field to route different work types through a single worker:
const worker = new Worker("notifications", async (job) => {
switch (job.name) {
case "welcome-email":
return sendWelcomeEmail(job.data);
case "password-reset":
return sendPasswordReset(job.data);
case "digest":
return sendDigestEmail(job.data);
default:
throw new Error(`Unknown job name: ${job.name}`);
}
});
Staged / Chained Jobs
For complex workflows with multiple stages, use BullMQ's FlowProducer. This lets you define a DAG (directed acyclic graph) of jobs where some jobs only start after others complete:
import { FlowProducer } from "bullmq";
const flow = new FlowProducer({ connection });
await flow.add({
name: "generate-thumbnail",
data: { imageId: 123 },
queueName: "image-processing",
children: [
{
name: "apply-watermark",
data: { imageId: 123 },
queueName: "image-processing",
children: [
{
name: "upload-to-cdn",
data: { imageId: 123 },
queueName: "image-processing",
},
],
},
],
});
FlowProducer is powerful but adds complexity — use it only when you genuinely need orchestrations that a single job can't handle.
Error Handling & Retry Strategies
Errors in BullMQ workers are inevitable. Network timeouts, API rate limit responses, and transient database failures all happen in production. The key is handling them gracefully.
Basic Try/Catch
At minimum, wrap your processor logic:
const worker = new Worker("payments", async (job) => {
try {
const result = await chargeCustomer(job.data);
return result;
} catch (error) {
console.error(`Job ${job.id} failed:`, error);
throw error; // re-throw to mark job as failed
}
});
Automatic Retries with Backoff
BullMQ supports automatic retries with configurable backoff strategies. Set attempts and backoff on the Queue's default job options or per-job:
const queue = new Queue("payments", {
connection,
defaultJobOptions: {
attempts: 5,
backoff: {
type: "exponential",
delay: 2000, // 2s, 4s, 8s, 16s, 32s
},
removeOnComplete: { count: 100 },
removeOnFail: { age: 24 * 3600 }, // keep failures for 24h
},
});
Exponential backoff prevents thundering herd problems during outages. With the settings above, a failing job retries after 2 seconds, then 4, 8, 16, and finally 32 seconds — giving the downstream system time to recover.
Custom Backoff Strategy
For more control, provide a custom backoff function:
const queue = new Queue("api-calls", {
connection,
defaultJobOptions: {
attempts: 10,
backoff: {
type: "custom",
delay: 1000,
},
},
});
// The custom strategy recalculates on each attempt
worker.retryIfFailed = async (job, error) => {
// Don't retry if the API returns 4xx (client error)
if (error.statusCode && error.statusCode < 500) {
return false; // permanently fail
}
// Otherwise use the configured backoff
return true;
};
Stalled Jobs
A job is marked as stalled when a worker picks it up but doesn't report progress, complete, or fail within the stalledInterval (default: 30 seconds). This usually means the worker crashed, the event loop was blocked, or the job took too long.
BullMQ detects stalls by checking the list of active jobs and verifying that the worker holding each lock is still alive. If a worker doesn't renew its lock in time, the job is picked up by another worker.
Prevent stalls by:
- Calling
job.updateProgress()periodically in long-running jobs - Setting
stalledIntervalto match your longest job duration - Configuring
maxStalledCountto limit retries on stalled jobs
const worker = new Worker("video-transcode", processor, {
stalledInterval: 60000, // check every 60 seconds
maxStalledCount: 2, // retry stalled jobs at most twice
});
Monitor stalls with the stalled event:
worker.on("stalled", (jobId) => {
console.warn(`Job ${jobId} has stalled`);
});
The Critical Error Listener
This is the most important code snippet in this article. Without an error listener on your Worker, certain Redis connection errors can crash your Node.js process:
const worker = new Worker("email", processor, { connection });
worker.on("error", (err) => {
console.error("Worker encountered an error:", err.message);
// Log to your monitoring system (Sentry, DataDog, etc.)
});
BullMQ internally emits an error event when it encounters unrecoverable Redis errors. If you don't listen for it, Node.js throws an unhandled error event and terminates the process.
Non-Retriable Errors
Some errors should never trigger a retry — for example, invalid input data or a missing user account. Use a conditional pattern:
const worker = new Worker("email", async (job) => {
if (!job.data.to || !job.data.to.includes("@")) {
// Non-retriable — move directly to failed
throw new NonRetriableError("Invalid email address");
}
await sendEmail(job.data);
});
BullMQ recognizes NonRetriableError (exported from bullmq) and will not retry the job even if attempts is set above 1.
Event Listeners & Lifecycle Hooks
BullMQ Workers emit events throughout the job lifecycle. These are the building blocks of observability.
Worker-Level Events
const worker = new Worker("email", processor, { connection });
worker.on("completed", (job, returnvalue) => {
logger.info({ jobId: job.id, returnvalue }, "Job completed");
});
worker.on("failed", (job, error) => {
logger.error({ jobId: job.id, error: error.message }, "Job failed");
});
worker.on("progress", (job, progress) => {
logger.info({ jobId: job.id, progress }, "Job progress update");
});
worker.on("drained", () => {
logger.info("Queue is empty — all jobs processed");
});
worker.on("active", (job) => {
logger.info({ jobId: job.id, name: job.name }, "Job started");
});
worker.on("error", (err) => {
logger.error({ error: err.message }, "Worker error");
});
QueueEvents (Global Observability)
Worker events are local — they only fire in the process that owns the worker. For global, multi-process monitoring, use QueueEvents. This class connects to Redis Streams (not pub/sub), which means it never misses events even if the consumer process restarts:
import { QueueEvents } from "bullmq";
const queueEvents = new QueueEvents("email", { connection });
queueEvents.on("completed", ({ jobId, returnvalue }) => {
console.log(`Job ${jobId} completed globally`);
});
queueEvents.on("failed", ({ jobId, failedReason }) => {
console.error(`Job ${jobId} failed: ${failedReason}`);
});
queueEvents.on("progress", ({ jobId, data }) => {
console.log(`Job ${jobId} is ${data}% complete`);
});
For full observability, combine both patterns:
- Worker events for logging and metrics in the worker process.
- QueueEvents for a centralized monitoring dashboard (like Queue Hub).
The Drained Event
The drained event fires when the queue has no more jobs. This is useful for gracefully winding down batch processing pipelines:
worker.on("drained", async () => {
console.log("All jobs done — initiating shutdown");
await worker.close();
process.exit(0);
});
Sandbox Workers
One of the most powerful features in BullMQ is the sandbox worker — it runs your processor in a separate child process. This is a game-changer for CPU-intensive work.
Why Sandbox Workers?
When a standard Worker's processor does CPU-heavy work (image resizing, video transcoding, PDF generation, data transformation), it blocks Node.js's event loop. While the event loop is blocked, other jobs can't progress, connection heartbeats are missed, and jobs get marked as stalled.
Sandbox workers solve this by running the processor in a forked process. If the child process crashes, it doesn't affect the main process. If it uses too much memory, Node.js isolates the damage.
Setting Up a Sandbox Worker
First, create a separate processor file:
// processors/image-processor.ts
import { Job, WorkerOptions } from "bullmq";
import sharp from "sharp";
export default async function (job: Job) {
const { inputPath, outputPath, width, height } = job.data;
// CPU-heavy image resize happens in a child process
await sharp(inputPath)
.resize(width, height)
.jpeg({ quality: 80 })
.toFile(outputPath);
return { outputPath, size: width + "x" + height };
}
Then reference it in your Worker:
// main.ts
import { Worker } from "bullmq";
const worker = new Worker(
"image-processing",
"./processors/image-processor.ts",
{
connection,
concurrency: 4,
}
);
BullMQ compiles and forks the processor file into a child process. Each concurrent job gets its own process, so you can exploit multiple CPU cores.
Worker Threads Alternative
BullMQ also supports worker threads (useWorkerThreads: true) instead of child processes. Threads share the same process memory space, which means faster communication but less isolation:
const worker = new Worker("image-processing", "./processors/image.ts", {
connection,
concurrency: 4,
useWorkerThreads: true,
});
When NOT to Use Sandbox Workers
Sandbox workers add overhead — spawning processes takes time (10–50ms) and each process consumes additional memory. Only use sandbox workers for CPU-bound jobs that take more than 100ms. For I/O-bound work (HTTP calls, database queries), standard in-process workers are more efficient.
Worker Lifecycle & Graceful Shutdown
When your application receives a termination signal (SIGTERM from Kubernetes or SIGINT from Ctrl+C), you need to shut down workers gracefully — finish in-progress jobs, stop accepting new ones, and close Redis connections.
Basic Worker Close
await worker.close();
close() tells the worker to stop processing new jobs, waits for active jobs to complete (optionally with a timeout), and then closes the Redis connection.
Graceful Shutdown with Signal Handlers
async function gracefulShutdown(signal: string) {
console.log(`Received ${signal}. Starting graceful shutdown...`);
// Stop accepting new jobs
await worker.close();
// Close QueueEvents if used
await queueEvents.close();
// Close the shared Redis connection
await connection.quit();
console.log("Shutdown complete");
process.exit(0);
}
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
Kubernetes Considerations
When running Workers on Kubernetes, set terminationGracePeriodSeconds in your pod spec to match the maximum expected job duration plus a buffer:
```yaml` apiVersion: v1 kind: Pod spec: terminationGracePeriodSeconds: 120 # 2 minutes for jobs to wrap up containers: - name: worker image: my-worker:latest
If your jobs can take up to 60 seconds, set `terminationGracePeriodSeconds: 90` to give the worker time to close gracefully before Kubernetes sends SIGKILL.
### Safety Net: Unhandled Rejections and Exceptions
Even with graceful shutdown, unexpected errors can slip through. Use process-level handlers as a last resort:
```typescript
process.on("unhandledRejection", (reason) => {
console.error("Unhandled Rejection:", reason);
// Optionally flush logs and exit
process.exit(1);
});
process.on("uncaughtException", (error) => {
console.error("Uncaught Exception:", error);
// Exit — process state may be corrupted
process.exit(1);
});
Note that these handlers should only be used for logging before crashing — after an uncaughtException, the application state is unreliable and the process should terminate.
Production Checklist
Here's a concise checklist to review before deploying BullMQ Workers to production.
Redis Configuration
- Set
maxmemory-policy noevictionso Redis never evicts queue data. - Enable AOF persistence with
appendfsync everysecto survive crashes. - Set
maxRetriesPerRequest: nullon the shared IORedis connection. - Use TLS for Redis connections over the network.
- Monitor Redis memory usage — queues can grow quickly under load.
Worker Configuration
- Set
concurrencybased on job type (I/O: 10–50, CPU-light: 4–8, CPU-heavy: use sandbox). - Configure a
limiterif jobs interact with rate-limited APIs. - Set
stalledIntervalandmaxStalledCountappropriately. - Always attach a
worker.on("error", ...)listener.
Job Configuration
- Set
attemptsandbackofffor reliable retry behavior. - Use
removeOnCompleteandremoveOnFailto prevent unbounded queue growth. - Use
NonRetriableErrorfor invalid data that shouldn't retry. - Add deduplication for idempotent jobs (BullMQ v5+).
Infrastructure
- Run multiple Worker instances or processes for redundancy.
- Implement graceful shutdown with SIGTERM/SIGINT handlers.
- Set
terminationGracePeriodSecondsin Kubernetes pod specs. - Use QueueEvents or Queue Hub for centralized monitoring.
Complete Job Configuration Example
import { Job, Queue, Worker, NonRetriableError } from "bullmq";
import IORedis from "ioredis";
const connection = new IORedis({
host: process.env.REDIS_HOST || "localhost",
port: parseInt(process.env.REDIS_PORT || "6379"),
tls: process.env.REDIS_TLS ? {} : undefined,
maxRetriesPerRequest: null,
retryStrategy: (times) => Math.min(times * 50, 2000),
});
const queue = new Queue("order-processing", {
connection,
defaultJobOptions: {
attempts: 5,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: { count: 500 },
removeOnFail: { age: 24 * 3600 },
},
});
const worker = new Worker(
"order-processing",
async (job: Job) => {
if (!job.data.orderId) {
throw new NonRetriableError("Missing orderId");
}
await processOrder(job.data);
return { processed: true, orderId: job.data.orderId };
},
{
connection,
concurrency: 10,
stalledInterval: 30000,
maxStalledCount: 3,
}
);
worker.on("error", (err) => console.error("Worker error:", err));
worker.on("completed", (job) =>
console.log(`Job ${job.id} completed`)
);
worker.on("failed", (job, err) =>
console.error(`Job ${job.id} failed: ${err.message}`)
);
async function shutdown(signal: string) {
console.log(`${signal} received — shutting down`);
await worker.close();
await queue.close();
await connection.quit();
process.exit(0);
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
Conclusion
BullMQ Workers are remarkably capable when configured correctly. We've covered the key areas that separate a hobby project worker from a production-grade one: thoughtful concurrency management, resilient retry strategies with exponential backoff and non-retriable error patterns, event-driven observability through both Worker events and QueueEvents, graceful shutdown that respects Kubernetes lifecycle, and sandbox workers for CPU-intensive processing.
Start with the minimal Worker example above, deploy it, and layer on complexity as you need it. Add rate limiting when you hit API quotas. Add sandbox workers when image processing slows down your event loop. Add QueueEvents monitoring when you have multiple worker instances and need a unified view.
And once you have all that running — get visibility. Debugging failed jobs by tailing logs across a dozen worker instances is exhausting. That's exactly why we built Queue Hub. To get full visibility into your queue health — live worker views, throughput charts, and job inspection — check out Queue Hub (queuehub.tech).
Related Articles
Best BullMQ Dashboard Alternatives in 2026: A Comprehensive Comparison
Comparing every BullMQ UI option side by side: Bull Board, Arena, Taskforce, QueueHub, and raw redis-cli. Feature matrices, pricing, pros and cons, and recommendations for every team size.
QueueHub vs pg-boss: Redis vs PostgreSQL as a Job Queue Backend
pg-boss uses PostgreSQL as a Node.js job queue, while BullMQ (monitored by QueueHub) uses Redis. We compare the two approaches across throughput, persistence, transactional queues, deployment, and when transactional enqueueing matters.
QueueHub vs Temporal: Job Queues vs Workflow Orchestration
Temporal is a workflow orchestration platform — a different category from Redis-backed job queues. We compare Temporal.io against BullMQ + QueueHub, covering complexity, use cases, durability, observability, and when to choose each.