How to Set Up BullMQ Workers: Patterns, Concurrency, and Error Handling
BullMQ is the most widely adopted Redis-backed job queue for Node.js, with over 5.9 million weekly downloads and 8,900+ GitHub stars. At the heart of any BullMQ-based system is the Worker — the component that pulls jobs from a queue, processes them, handles failures, and reports progress. Getting the Worker setup right determines whether your background job pipeline is reliable, scalable, and production-ready.
In this post, we'll cover everything you need to set up BullMQ Workers in Node.js and TypeScript: basic instantiation, concurrency patterns, Redis connection management, error handling with retries and backoff, sandboxed processors for CPU-heavy work, graceful shutdown, and monitoring with QueueHub for real-time visibility.
Prerequisites
Before diving in, make sure you have:
- Node.js 18+ and npm, yarn, pnpm, or bun
- A running Redis instance — locally (Redis or Valkey), via Docker, or a cloud provider (Redis Cloud, AWS ElastiCache, Upstash)
- BullMQ installed:
npm install bullmqorbun add bullmq - IORedis — BullMQ uses IORedis internally and includes it as a peer dependency
- Basic familiarity with TypeScript (optional but highly recommended)
If you don't have Redis locally, spin one up quickly:
docker run -d -p 6379:6379 redis:7-alpine
BullMQ Worker Basics
What Is a Worker?
A Worker in BullMQ is the processing unit that pulls jobs from a queue and executes them via a processor function. Think of it as the consumer half of a producer-consumer pattern — the Queue adds jobs, the Worker processes them.
Under the hood, each Worker uses a blocking BRPOPLPUSH-style mechanism on Redis. This means workers do not poll — they block efficiently on Redis until a job is available, then immediately claim and process it. Each Worker instance consumes at least one Redis connection (plus a shared blocking connection duplicated internally).
Creating Your First Worker
Here's the simplest possible Worker in TypeScript:
import { Worker, Job } from "bullmq";
const worker = new Worker(
"email",
async (job: Job) => {
console.log(`Processing job ${job.id}: ${job.name}`);
const { to, subject, body } = job.data;
await sendEmail(to, subject, body);
return { sent: true, to };
},
{
connection: {
host: "localhost",
port: 6379,
},
}
);
worker.on("completed", (job: Job, returnvalue: any) => {
console.log(`Job ${job.id} completed`, returnvalue);
});
worker.on("failed", (job: Job | undefined, error: Error) => {
console.error(`Job ${job?.id} failed:`, error.message);
});
// CRITICAL: Always attach an 'error' listener to prevent worker stoppage
worker.on("error", (err: Error) => {
console.error("Worker error:", err);
});
Three things to notice here:
- The processor function is the second argument — it receives the
Jobinstance and returns a value or throws an error. - We attach event listeners for
completed,failed, anderror. - The error event listener is critical. Without it, BullMQ will crash the worker on Redis connection errors. Always add one.
The Processor Function
The processor function is the core of your worker. It must be an async function (or return a Promise). It receives the Job instance, an optional token string, and an optional AbortSignal for cancellation:
const worker = new Worker<string, boolean>(
"my-queue",
async (job: Job<string>, token?: string, signal?: AbortSignal) => {
// Check for cancellation
if (signal?.aborted) {
throw new Error("Job was cancelled");
}
await job.updateProgress(25);
// ... processing ...
await job.updateProgress(50);
// ... more processing ...
await job.updateProgress(100);
return true;
},
{ connection }
);
The job's return value is stored as job.returnvalue and emitted in the completed event. Use job.updateProgress() to report progress to monitoring dashboards.
TypeScript Generics with Worker
BullMQ Workers are fully typed with generics. You can type the job data payload and the return value:
interface EmailData {
to: string;
subject: string;
body: string;
}
interface EmailResult {
sent: boolean;
messageId: string;
}
const worker = new Worker<EmailData, EmailResult>(
"email",
async (job: Job<EmailData, EmailResult>) => {
const result = await sendEmail(job.data);
return result;
},
{ connection }
);
Redis Connections
Connection Options
BullMQ uses IORedis under the hood. Connection options are passed directly to the IORedis constructor. Here are common deployment patterns:
// Local Redis
const worker = new Worker("queue", processor, {
connection: { host: "localhost", port: 6379 },
});
// Redis with TLS (common with cloud providers)
const worker = new Worker("queue", processor, {
connection: {
host: "myredis.tls.com",
port: 6380,
tls: {},
password: "my-password",
},
});
// AWS ElastiCache
const worker = new Worker("queue", processor, {
connection: {
host: "my-cluster.xxxxxx.region.cache.amazonaws.com",
port: 6379,
},
});
// Valkey (compatible Redis fork)
const worker = new Worker("queue", processor, {
connection: {
host: "my-valkey.instance",
port: 6379,
},
});
Reusing Redis Connections (Best Practice)
Never create a separate Redis connection for every Queue, Worker, and QueueEvents instance. Share one IORedis instance across all BullMQ components:
import IORedis from "ioredis";
import { Queue, Worker, QueueEvents } from "bullmq";
const connection = new IORedis({
host: "localhost",
port: 6379,
maxRetriesPerRequest: null,
});
const queue = new Queue("my-queue", { connection });
const worker = new Worker("my-queue", async (job) => {
// process job
}, { connection });
const queueEvents = new QueueEvents("my-queue", { connection });
Setting maxRetriesPerRequest: null is important — it prevents IORedis from automatically retrying commands that fail (BullMQ handles retries internally).
Concurrency
The Concurrency Option
By default, a BullMQ Worker processes one job at a time. The concurrency option controls how many jobs the worker processes simultaneously:
const worker = new Worker("queue", processor, {
connection,
concurrency: 10,
});
This worker will process up to 10 jobs concurrently. The right concurrency setting depends on your workload:
- I/O-bound tasks (API calls, database queries, file uploads): High concurrency (20-50+) works well because the event loop isn't blocked during I/O waits.
- CPU-bound tasks (image processing, video transcoding, data transformation): Keep concurrency low (1-4) or use sandboxed processors (covered below).
- Memory-intensive tasks: Low concurrency (1-2) to avoid OOM errors.
Limiter (Rate Limiting)
For integration with rate-limited APIs, combine concurrency with a limiter:
const worker = new Worker("api-queue", processor, {
connection,
concurrency: 10,
limiter: {
max: 5, // max 5 jobs
duration: 1000, // per second
},
});
This ensures no more than 5 jobs are processed per second across all concurrent slots.
Error Handling
Retry Strategies with Backoff
BullMQ's retry mechanism is one of its most powerful features. You configure retries per job when adding to the queue:
// Per-job retry configuration
await queue.add("email", data, {
attempts: 3,
backoff: {
type: "exponential",
delay: 2000,
},
});
// Or set as defaults on the Queue
const queue = new Queue("email", {
connection,
defaultJobOptions: {
attempts: 3,
backoff: { type: "exponential", delay: 1000 },
},
});
Backoff Strategies
BullMQ provides three backoff strategies:
| Strategy | Behavior | Use Case |
|---|---|---|
fixed |
Same delay between each retry | Transient issues, rate limits |
exponential |
2^(attempts-1) × delay |
Server errors, network blips |
custom |
User-defined function | Complex retry logic |
Here's how to use them:
// Fixed with jitter
await queue.add("job", data, {
attempts: 5,
backoff: { type: "fixed", delay: 5000, jitter: 0.3 },
});
// Custom backoff strategy (defined on the Worker)
const worker = new Worker("queue", processor, {
settings: {
backoffStrategy: (attemptsMade: number, type?: string, err?: Error, job?: Job) => {
// Linear: 1s, 2s, 3s, ...
return attemptsMade * 1000;
},
},
});
// Job opts reference it as 'custom'
await queue.add("job", data, {
attempts: 3,
backoff: { type: "custom" },
});
Stalled Jobs
A job becomes stalled when a worker fails to renew its processing lock. This typically happens when:
- The worker's event loop is blocked by CPU-heavy processing
- The worker crashed unexpectedly
- Network issues prevent the lock renewal heartbeat
BullMQ detects stalled jobs and moves them back to the waiting queue for re-processing. After maxStalledCount (default: 1), the job moves to the failed state permanently.
Prevention: Use sandboxed processors for CPU-heavy work, keep concurrency at reasonable levels, and avoid blocking the event loop with synchronous operations.
The Failed Event
Listen to the failed event for detailed error tracking and alerting:
worker.on("failed", (job: Job | undefined, error: Error, prev: string) => {
console.error(`Job ${job?.id} failed after ${job?.attemptsMade} attempts`);
console.error(`Error: ${error.message}`);
console.error(`Previous state: ${prev}`);
// Send to monitoring/alerting
await notifyFailure(job, error);
});
Stopping Retries Conditionally
For non-retryable errors — validation failures, authentication errors, bad input — you can stop retrying by returning -1 from a custom backoff strategy:
import { NonRetryableError } from "bullmq";
const worker = new Worker("queue", processor, {
settings: {
backoffStrategy: (attemptsMade, type, err) => {
if (err instanceof NonRetryableError) {
return -1; // Stop retrying, move to failed permanently
}
return attemptsMade * 5000;
},
},
});
This is a clean pattern for distinguishing transient failures (retry) from permanent ones (move to failed immediately).
Sandboxed Workers (Separate Process)
When to Use Sandboxed Processors
For CPU-heavy or memory-intensive work, running the processor in the same Node.js process as the worker can block the event loop, causing stalled jobs and degraded performance. Sandboxed processors solve this by running the job in a separate process (or thread).
Use sandboxed processors when:
- Performing CPU-heavy operations like image processing, PDF generation, or video transcoding
- Running memory-intensive computations
- Isolating unstable third-party code that might crash
- Preventing stalled jobs caused by lock renewal delays during heavy processing
Creating a Sandboxed Processor
Create a separate file for the processor:
// processor.ts (separate file)
import { SandboxedJob } from "bullmq";
export default async (job: SandboxedJob) => {
// CPU-heavy work here — won't affect the main worker process
const result = await performHeavyComputation(job.data);
return result;
};
Then reference it in your Worker by file path:
// worker.ts
import path from "path";
import { Worker } from "bullmq";
const processorFile = path.join(__dirname, "processor.js");
const worker = new Worker("heavy-queue", processorFile, {
connection,
concurrency: 2, // Keep low for CPU-bound work
useWorkerThreads: true, // Use worker_threads instead of child_process
});
Worker Threads vs Child Processes
By default, BullMQ spawns a new child process per sandboxed job. Setting useWorkerThreads: true switches to Node.js worker_threads, which are lighter (shared memory space, no process overhead). For ESM compatibility on Windows, use the URL-based path:
const processorUrl = new URL("./processor.js", import.meta.url);
const worker = new Worker("heavy-queue", processorUrl.href, {
connection,
useWorkerThreads: true,
});
Worker Lifecycle & Events
Worker Events Reference
BullMQ Workers emit several lifecycle events you can listen to:
worker.on("completed", (job: Job, returnvalue: any) => {
// Job finished successfully
});
worker.on("failed", (job: Job | undefined, error: Error, prev: string) => {
// Job processing threw an error
});
worker.on("progress", (job: Job, progress: number | object) => {
// Job reported progress via job.updateProgress()
});
worker.on("drained", () => {
// Queue is empty — no more jobs to process
});
worker.on("error", (err: Error) => {
// Connection or internal error — ALWAYS listen to this
});
worker.on("active", (job: Job) => {
// Job has started processing
});
worker.on("closing", (msg: string) => {
// Worker is shutting down
});
Global Events with QueueEvents
For monitoring across all worker instances (useful in multi-process or distributed deployments), use QueueEvents:
import { QueueEvents } from "bullmq";
const queueEvents = new QueueEvents("my-queue", { connection });
queueEvents.on("completed", ({ jobId, returnvalue }) => {
console.log(`Job ${jobId} completed globally`);
});
queueEvents.on("failed", ({ jobId, failedReason }) => {
console.log(`Job ${jobId} failed: ${failedReason}`);
});
queueEvents.on("progress", ({ jobId, data }) => {
console.log(`Job ${jobId} progress:`, data);
});
QueueEvents uses Redis streams, which are reliable, auto-trimmed, and survive disconnections. This makes it ideal for centralized monitoring.
Graceful Shutdown
Why Graceful Shutdown Matters
When your application receives a shutdown signal (SIGTERM from Kubernetes, SIGINT from Ctrl+C), you need to:
- Stop accepting new jobs
- Let active jobs complete (or move them back to the waiting queue)
- Cleanly close Redis connections
Without graceful shutdown, you risk abandoning mid-processing jobs and leaving dangling Redis connections.
Shutdown Sequence
async function shutdown(worker: Worker, queue: Queue, signal: string) {
console.log(`Received ${signal}. Starting graceful shutdown...`);
// 1. Stop accepting new jobs — waits for active jobs by default
await worker.close();
// 2. Close the queue connection
await queue.close();
console.log("Graceful shutdown complete");
process.exit(0);
}
// In your application
const worker = new Worker("queue", processor, { connection });
const queue = new Queue("queue", { connection });
process.on("SIGTERM", () => shutdown(worker, queue, "SIGTERM"));
process.on("SIGINT", () => shutdown(worker, queue, "SIGINT"));
Close Options
// Force close — don't wait for active jobs
await worker.close(true); // force = true
// Close with custom timeout (default: wait indefinitely)
await worker.close(false, 30_000); // 30s timeout for active jobs
Monitoring Workers with QueueHub
The Visibility Gap
Even with perfect code, production workers can encounter issues that are invisible without proper monitoring: stuck jobs, stalled workers, memory leaks, concurrency bottlenecks, and failure spikes. Logs alone don't give you a real-time picture of queue health.
What QueueHub Shows
QueueHub provides a dedicated dashboard for your BullMQ queues that reveals:
- Live Worker View: See active workers, their concurrency settings, and which jobs they're processing in real time
- Job Detail View: Inspect job data, return values, error stack traces, and retry history
- Queue Overview: Throughput charts, failure rates, and processing latency at a glance
- Multi-Backend Support: Connect to any Redis deployment (local, TLS, Valkey, AWS ElastiCache) via connection settings or agent tunneling for private Redis instances
- Team Collaboration: Share queue visibility across your whole team with multi-org support and team invitations
Connecting QueueHub to Your Workers
Point QueueHub at your Redis instance — it works across local development, staging, and production environments. Supports TLS-secured Redis, AWS ElastiCache, Valkey, and private backends via agent tunneling.
Production Best Practices — Summary
- Always attach an
errorlistener — prevents silent worker stoppage when Redis connections hiccup - Reuse Redis connections — share one IORedis instance across Queue, Worker, and QueueEvents
- Set
maxRetriesPerRequest: nullon reused IORedis connections to avoid automatic reconnect issues - Match concurrency to workload — high for I/O-bound tasks, low for CPU-bound or memory-intensive work
- Use exponential backoff for transient failures, custom backoff with
-1return for non-retryable errors - Sandbox CPU-heavy processors — prevents stalled jobs from lock renewal delays
- Implement graceful shutdown — handle SIGTERM and SIGINT to avoid job abandonment
- Set
defaultJobOptionson the Queue to avoid repeating retry/backoff config per job - Monitor with QueueHub — real-time visibility into worker health, job failures, and queue performance
- Use QueueEvents for global monitoring across all worker instances in distributed deployments
Conclusion
BullMQ Workers are the backbone of reliable background job processing. Proper setup involves more than just a processor function — you need to manage Redis connections efficiently, tune concurrency to your workload, implement robust error handling with retries and backoff, use sandboxed processors for CPU-heavy work, and handle graceful shutdown signals.
The patterns covered in this post will serve you well in production. For your next steps:
- Try these patterns in your own project
- Explore the official BullMQ documentation for advanced topics like rate limiting, migrations, and job schedulers
- Set up QueueHub for real-time visibility into your queues — don't debug worker issues blind
Appendix: Quick Reference
Worker Constructor Signature
new Worker<TData = any, TReturn = any, TName extends string = string>(
name: string,
processor:
| string
| ((
job: Job<TData, TReturn, TName>,
token?: string,
signal?: AbortSignal
) => Promise<TReturn>),
opts?: WorkerOptions
);
interface WorkerOptions {
connection?: IORedis.Redis | IRedisClient | ConnectionOptions;
concurrency?: number;
autorun?: boolean;
limiter?: RateLimiterOptions;
skipStalledCheck?: boolean;
stalledInterval?: number;
maxStalledCount?: number;
removeOnComplete?: KeepJobs;
removeOnFail?: KeepJobs;
useWorkerThreads?: boolean;
settings?: WorkerSettings;
}
Common Patterns Quick Copy
// Minimal setup
new Worker("queue", async (job) => { /* ... */ }, { connection });
// With concurrency + rate limit
new Worker("queue", async (job) => { /* ... */ }, {
connection,
concurrency: 10,
limiter: { max: 5, duration: 1000 },
});
// With retry-friendly jobs
queue.add("job", data, {
attempts: 5,
backoff: { type: "exponential", delay: 1000 },
});
// Graceful shutdown
await worker.close();
await queue.close();
Last updated: June 2026 — compatible with BullMQ v5+
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.