Running BullMQ Workers in Production
BullMQ is the de facto Redis queue for Node. Writing producers is easy. Writing consumers is easy. Running consumers in production — keeping them alive, sized right, and not leaving half-processed jobs behind during deploys — is the part where teams accumulate scar tissue.
This post is the supervision and lifecycle layer around BullMQ workers, not a BullMQ tutorial.
The Two-Process Reality
A typical BullMQ setup has at least two processes:
- API — your web server, which adds jobs to queues.
- Worker — a separate process that consumes jobs.
Don’t run workers in the API process. Workers have unpredictable runtimes, occasional CPU spikes, and you don’t want a slow image-resize job to make HTTP requests time out. Separate processes mean separate memory footprints, separate restart policies, and separate observability.
A minimal worker process:
// worker.ts
import { Worker } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null,
});
const worker = new Worker(
'emails',
async (job) => {
await sendEmail(job.data);
},
{
connection,
concurrency: 10,
}
);
worker.on('failed', (job, err) => {
console.error(`job ${job?.id} failed`, err);
}); maxRetriesPerRequest: null is required by BullMQ on the Redis connection — without it, you’ll get cryptic timeouts under load.
Supervision
Each worker process needs a supervisor. With Oxmgr you describe API and worker together:
# oxfile.toml
[processes.api]
command = "node dist/server.js"
cwd = "/srv/myapp"
env_file = ".env"
restart = "on-failure"
stop_signal = "SIGTERM"
stop_timeout = "20s"
[processes.worker]
command = "node dist/worker.js"
cwd = "/srv/myapp"
env_file = ".env"
restart = "always"
stop_signal = "SIGTERM"
stop_timeout = "60s"
[processes.worker.limits]
memory = "512M" Two things to notice:
stop_timeout = "60s"on the worker. Workers might be in the middle of a long job. They need time to finish before being killed. If your jobs can take 5 minutes, set this to 5 minutes plus a buffer.restart = "always"on the worker. Workers should always come back. A worker that exits cleanly when there are no jobs is wrong — it should sleep and wait.
For the supervisor primer, see what is a process manager.
Graceful Shutdown — The Job-Loss Problem
This is the bug that bites everyone exactly once. The story:
- You deploy.
- Supervisor sends
SIGTERMto the worker. - The worker has 5 jobs currently processing.
- The worker exits immediately.
- Those 5 jobs are now stuck in Redis with
activestate. - BullMQ’s stalled-check eventually moves them back to
waiting— but only afterlockDurationexpires (default 30s). - Until then, those jobs are invisible. After, they retry from scratch.
The fix is to handle SIGTERM explicitly and let active jobs finish:
const shutdown = async () => {
console.log('worker draining...');
await worker.close();
await connection.quit();
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown); worker.close() stops fetching new jobs and waits for active ones to finish. Combined with a supervisor stop_timeout longer than your longest job, you get zero job loss on deploys.
The general shutdown pattern is in the graceful shutdown guide. The BullMQ-specific addition is worker.close().
Concurrency vs Process Count
You have two knobs:
- Concurrency per worker (the
concurrencyoption) — how many jobs a single Node process handles in parallel. - Worker process count — how many separate Node processes you run.
The trade-offs:
- High concurrency, one process: lowest memory overhead. But one slow job blocks the others on the event loop if the job code is CPU-heavy.
- Low concurrency, many processes: higher memory floor, but better isolation. If one process OOMs, the others keep working.
For I/O-bound jobs (sending emails, calling APIs): concurrency: 20, one process.
For CPU-bound jobs (image processing, PDF generation): concurrency: 1, multiple processes.
For mixed workloads: separate queues with separate worker processes. Don’t put send-email and transcode-video in the same worker.
Memory Limits
Workers leak. They process jobs with varying payload sizes, instantiate heavy libraries (image processing, PDF tools), and accumulate state if you’re not careful. A memory cap turns a leak into a clean restart:
[processes.worker.limits]
memory = "512M" When the worker hits the cap, the supervisor SIGKILLs it. BullMQ’s stalled-job recovery picks up where it left off. The next worker process starts fresh.
This is one of the reasons to have multiple worker processes: a single OOM doesn’t pause your whole queue. The resource limits post covers the supervisor side; the memory leaks post covers diagnosis.
Health Checks for Workers
Workers don’t have HTTP endpoints by default. Two options:
Option A: Expose a tiny health server from the worker process:
import http from 'node:http';
http.createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(worker.isPaused() ? 503 : 200);
res.end('ok');
return;
}
res.writeHead(404);
res.end();
}).listen(9100); Your supervisor pings http://127.0.0.1:9100/health. Use a different port for each worker process.
Option B: Use a heartbeat file the worker touches periodically; supervisor checks staleness.
Option A is cleaner. The health checks guide covers the HTTP pattern.
Stuck Jobs and Stalled Checks
Even with clean shutdown, jobs can stall: the worker process gets SIGKILL‘d, the box loses power, the network partitions. BullMQ handles this via lock heartbeats — every lockDuration / 2 ms, the worker renews its lock on the active job. If the lock expires, the job is considered stalled and moved back to waiting.
Production tuning:
new Worker('emails', handler, {
connection,
concurrency: 10,
lockDuration: 30_000, // 30s
maxStalledCount: 1, // retry stalled jobs once before failing
}); If your jobs take longer than 30 seconds, increase lockDuration. If your supervisor’s stop_timeout is less than lockDuration, you’ll see stalled jobs on every deploy.
Order of magnitudes that should match:
supervisor stop_timeout > job runtime > lockDuration / 2 Observability
Run Bull Board on a separate route (admin-protected) for a UI to inspect queues. For production metrics, expose Prometheus counters from the worker process and scrape them from your monitoring stack.
The metrics worth tracking:
- Jobs processed per second per queue.
- Job duration p50, p95, p99.
- Failed job count.
- Waiting queue depth (alerts when this grows unboundedly).
A Common Mistake: Single Redis for Queue and Cache
If your queue Redis is the same instance as your cache Redis, a memory spike from cache eviction can break the queue. Run separate Redis instances (or separate logical databases at minimum). For production, separate instances are safer.
Deploys
Standard process:
- Stop workers, wait for drain.
- Deploy new code.
- Start workers.
The supervisor does the first and third steps; you do the second. With Oxmgr:
# After rsyncing new code:
oxm reload worker This sends SIGTERM, waits for stop_timeout, then starts the new version. As long as stop_timeout > max job duration, no jobs are lost.
For automated deploys, see the git webhook deploy guide.
Common Pitfalls
- No
worker.close()on SIGTERM. Job loss on every deploy. stop_timeoutshorter than job runtime. Same outcome.- Workers in the API process. Slow jobs block HTTP requests.
- Same Redis for queue and cache. Cache evictions can corrupt queue state.
maxRetriesPerRequest: nullmissing. Cryptic timeouts under load.- Running multiple workers without
concurrencytuning. You can saturate Redis with thousands of concurrent in-flight jobs.
Bottom Line
BullMQ in production is a story about boundaries: separate process from API, separate queue Redis from cache Redis, separate worker processes by job type. A supervisor that sends SIGTERM and waits long enough turns deploys into a non-event.
Oxmgr handles API + workers + health endpoints from one config file, with per-process memory caps and stop-timeout tuning that match what BullMQ actually needs.