In Node.js's single-threaded concurrency model, executing CPU-intensive computations (such as parsing multi-gigabyte JSON files or rendering video transformations) or heavyweight batch I/O operations (such as generating 500,000-row CSV/XLSX exports or dispatching thousands of external webhooks) directly inside an HTTP request lifecycle is fundamentally unacceptable. Doing so blocks the Event Loop, precipitates severe Event Loop Delay spikes, and starves inbound network connections.
The production-grade architectural pattern is delegating asynchronous tasks to distributed message queues. BullMQ is an enterprise job queue library for Node.js and TypeScript constructed on top of Redis native data structures. Unlike traditional enterprise brokers (RabbitMQ, Apache Kafka), BullMQ unites sub-millisecond in-memory processing speeds with sophisticated workflow primitives: delayed executions, cron schedules, priority ordering, distributed rate limiting, and Directed Acyclic Graph (DAG) parent-child dependency flows.
BullMQ does not simulate queues via abstract polling loops; it operates directly on native Redis primitives controlled through pre-compiled, atomic Lua scripts:
QueueEvents to broadcast lifecycle transitions between workers and listening clients with minimal CPU and memory overhead.zset): Power delayed tasks (delayed), scheduled jobs (repeatable), and prioritized work (prioritized). The score represents a millisecond Unix timestamp or an integer priority rank.hash): Store granular metadata for each distinct task (job:id): input payload, attempts made (attemptsMade), stack traces (stacktrace), execution progress, and return values.list): Form FIFO/LIFO wait queues (wait, paused) and completed/failed registries. Atomic item migrations between lists are executed using Redis RPOPLPUSH / LMOVE primitives.A BullMQ subsystem architecture is composed of three core entities: Queue (producer), Worker (consumer), and QueueEvents (distributed lifecycle observer).
[ HTTP API Handler ]
|
v (queue.add)
+-----------------------+
| BullMQ Queue |
+-----------------------+
|
v (Atomic Lua Script)
+-------------------------------------+
| Redis State |
| - zset: delayed (score: timestamp) |
| - list: wait (FIFO / LIFO) |
| - hash: job metadata |
+-------------------------------------+
|
| Worker acquires lock
v (LMOVE + SET lock NX PX)
+-----------------------+
| BullMQ Worker | <----+ Heartbeat Lock Renewal
| (concurrency: N) | | (lockRenewTime: 15s)
+-----------------------+ |
| |
Is Event Loop blocked? -----+
Worker crashed (OOM)?
|
v Lock Expired (30s)
+-------------------------------------+
| Stalled Job Recovery Loop |
| Moves job back to 'wait' or 'fail' |
+-------------------------------------+Every job traverses a deterministic finite state machine throughout its lifecycle:
+------------+
| Delayed | (Scheduled timestamp reached)
+------------+
|
v
+------------+ Worker fetches
| Wait | ------------------------+
+------------+ |
^ v
| Retry / Stalled +------------+
+------------------------- | Active |
+------------+
/ \
Success Result / \ Failure / Max Attempts
v v
+-----------+ +-----------+
| Completed | | Failed |
+-----------+ +-----------+concurrency to match available host CPU cores.Resilient background job execution in Node.js requires structuring the system into three tiers:
lockDuration), concurrency throttling, and sandboxing.FlowProducer.import { Queue, Worker, QueueEvents, Job } from "bullmq";
import Redis from "ioredis";
export interface PaymentWebhookJobData {
transactionId: string;
amount: number;
currency: string;
source: string;
timestamp: number;
}
export interface PaymentJobResult {
processed: boolean;
ledgerRecordId: string;
}
const redisConnection = new Redis(process.env.REDIS_URL || "redis://127.0.0.1:6379", {
maxRetriesPerRequest: null, // MANDATORY requirement for BullMQ operation!
enableReadyCheck: false,
});
export const PAYMENT_QUEUE_NAME = "payment-webhooks";
/**
* Queue Producer Initialization
*/
export const paymentQueue = new Queue<PaymentWebhookJobData, PaymentJobResult>(
PAYMENT_QUEUE_NAME,
{
connection: redisConnection,
defaultJobOptions: {
attempts: 5, // Up to 5 retries on failure
backoff: {
type: "exponential",
delay: 2000, // Initial delay 2s, then 4s, 8s, 16s...
},
removeOnComplete: {
age: 86400, // Keep completed jobs in Redis for 24 hours
count: 5000, // Retain a maximum of 5,000 completed records
},
removeOnFail: {
age: 604800, // Retain failed jobs for 7 days for audit and dead-letter review
},
},
}
);
/**
* Resilient Worker Consumer with Stalled Job Protection
*/
export function createPaymentWorker(): Worker<PaymentWebhookJobData, PaymentJobResult> {
const worker = new Worker<PaymentWebhookJobData, PaymentJobResult>(
PAYMENT_QUEUE_NAME,
async (job: Job<PaymentWebhookJobData, PaymentJobResult>): Promise<PaymentJobResult> => {
// Broadcast execution progress for live dashboard monitoring
await job.updateProgress(10);
if (job.data.amount <= 0) {
throw new Error(`Invalid transaction amount: ${job.data.amount}`);
}
await job.updateProgress(50);
// Business logic execution
const ledgerRecordId = `led_${job.data.transactionId}`;
await job.updateProgress(100);
return {
processed: true,
ledgerRecordId,
};
},
{
connection: redisConnection,
concurrency: 10, // Up to 10 concurrent jobs per worker process
lockDuration: 30000, // Lock lease duration (30 seconds)
lockRenewTime: 10000, // Renew lock lease every 10 seconds (Watchdog)
maxStalledCount: 2, // Maximum 2 stalls before permanently marking as Failed
stalledInterval: 30000, // Interval for scanning stalled jobs
}
);
// Critical telemetry and lifecycle hooks
worker.on("completed", (job: Job) => {
console.info(`[Worker] Job ${job.id} completed successfully`);
});
worker.on("failed", (job: Job | undefined, err: Error) => {
console.error(`[Worker] Job ${job?.id} failed on attempt ${job?.attemptsMade}: ${err.message}`);
});
worker.on("error", (err: Error) => {
console.error("[Worker] Global worker error:", err.message);
});
worker.on("stalled", (jobId: string) => {
console.warn(`[Worker] Job ${jobId} was marked as STALLED (lock expired)`);
});
return worker;
}import { FlowProducer, FlowJob } from "bullmq";
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL || "redis://127.0.0.1:6379", {
maxRetriesPerRequest: null,
});
export const flowProducer = new FlowProducer({ connection: redis });
/**
* Creates a hierarchical dependency pipeline:
* The parent aggregation job waits for two parallel data extraction jobs to resolve
*/
export async function createReportPipeline(reportId: string, companyId: string) {
const flow: FlowJob = {
name: "aggregate-final-report",
queueName: "report-aggregator",
data: { reportId, companyId },
children: [
{
name: "extract-sales-data",
queueName: "data-extraction",
data: { reportId, source: "sales" },
opts: { attempts: 3 },
},
{
name: "extract-expenses-data",
queueName: "data-extraction",
data: { reportId, source: "expenses" },
opts: { attempts: 3 },
},
],
};
const tree = await flowProducer.add(flow);
return tree.job.id;
}An atomic Lua script writes job metadata into a Hash and places the ID into the wait list or delayed zset.
The worker atomically moves the job from wait to active via LMOVE and claims a distributed lock for lockDuration.
Every 10 seconds, a background heartbeat timer executes a Lua PEXPIRE script to extend the lock by 30 seconds.
On success, return values are written to the Hash, the job is moved to completed, and the lock is deleted.
If a worker crashes, the lock expires. A peer worker detects the expired lock and returns the job to wait for retry.
version: "3.8"
services:
redis-cluster:
image: redis:7.2-alpine
command: redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy noeviction
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
queue-worker:
build:
context: .
dockerfile: Dockerfile.worker
deploy:
replicas: 4 # Scale to 4 independent Node.js processes
resources:
limits:
cpus: "1.5"
memory: 1024M
environment:
NODE_ENV: production
REDIS_URL: redis://redis-cluster:6379
WORKER_CONCURRENCY: 10
stop_grace_period: 45s # 45-second window for in-flight jobs to conclude cleanly
restart: on-failure
volumes:
redis_data:Executing heavy synchronous operations (such as parsing a 100 MB XML payload via JSON.parse or running bcrypt.hashSync) directly inside the new Worker(...) handler function. The main thread freezes for 15 seconds. During this freeze, the internal lock renewal timer (lockRenewTime) cannot execute! Consequently, lockDuration expires, and a peer worker declares the job Stalled, picking it up and causing duplicate parallel execution. Solution: Run heavy workloads using a SandboxedProcessor (in a separate Worker Thread or Child Process) or use streaming asynchronous APIs.
By default, ioredis limits retries on commands. During transient network drops, BullMQ relies on command buffering until reconnection. Under default settings, ioredis throws a fatal MaxRetriesPerRequestError, terminating worker processes and dropping job state. Always configure maxRetriesPerRequest: null on Redis connections assigned to BullMQ.
Enqueuing millions of jobs without specifying removeOnComplete and removeOnFail. Every completed job leaves its metadata Hash in Redis memory indefinitely. Over time, millions of stale records exhaust Redis host memory, triggering OOM command not allowed write rejections. Always enforce retention limits (age: 86400, count: 5000).
Relying on in-memory JavaScript timers inside services scaled across multiple Kubernetes pods. Each of the 5 running pods fires its own setInterval, duplicating scheduled tasks (such as subscription billing). BullMQ's Repeatable Jobs synchronize schedules centrally in Redis, guaranteeing that exactly one worker executes the scheduled task cluster-wide.
Worker terminates due to SIGKILL (OOM) or stalls inside a synchronous compute loop
The 30-second lockDuration lease expires in Redis because no renewal heartbeats were sent
An active peer worker scans active keys and identifies a job without a valid lock
Increments job.stalledCount; if under maxStalledCount, moves job back to wait list
If the stalled limit is exceeded, the job is permanently transitioned to failed