BullMQ vs BeeQueue: Operational Realities in Production
Feature comparisons and tutorial walkthroughs tell you what a queue library can do. Production operations tell you what a library is like to live with — the messy realities that don't make it into the README.
If you've read any BullMQ vs BeeQueue comparison, you've seen the feature table: checkmarks for retries, priorities, flows, rate limiting. But none of those tables tell you what happens when a worker pod crashes at 3 AM, when Redis memory fills up, or when you need to upgrade the library without dropping jobs.
This post covers the operational side: deployment patterns, monitoring stacks, debugging real production failures, Redis configuration, upgrade strategies, and the DevOps workflows each library supports (or where you're on your own).
Section 1: Deployment Patterns — Containers, Orchestrators, and Process Managers
BullMQ: Container-Native from the Ground Up
BullMQ's architecture — with separate Queue, Worker, and QueueEvents classes — maps naturally to containerized environments. Workers are designed to be long-running processes with graceful shutdown, health probes, and signal handling as first-class concerns.
Here's a production-grade BullMQ worker with Kubernetes health endpoints and graceful shutdown:
import { Worker, Queue } from 'bullmq';
import { Redis } from 'ioredis';
import express from 'express';
const connection = new Redis({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
maxRetriesPerRequest: null,
retryStrategy: (times) => Math.min(Math.exp(times) * 100, 20000),
});
const worker = new Worker('my-queue', async (job) => {
// your processor logic
}, {
connection,
concurrency: Number(process.env.WORKER_CONCURRENCY ?? 5),
lockDuration: 30_000,
stalledInterval: 15_000,
maxStalledCount: 2,
});
const app = express();
app.get('/health/live', (_, res) => res.sendStatus(200));
app.get('/health/ready', async (_, res) => {
try {
await connection.ping();
if (worker.isRunning()) return res.sendStatus(200);
res.sendStatus(503);
} catch {
res.sendStatus(503);
}
});
async function shutdown(signal: string) {
console.log(`Received ${signal}, closing worker...`);
await worker.close();
await connection.quit();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
app.listen(8080);
Key deployment considerations for BullMQ:
- Kubernetes HPA: Scale workers on CPU/memory with
stabilizationWindowSeconds: 300on scale-down to prevent thrashing - KEDA integration: Redis list-length trigger (
bull:queueName:wait) enables queue-depth-based autoscaling — workers spin up when the backlog grows - Pod Disruption Budget:
minAvailable: 1prevents total worker loss during node drains - Grace period: Set
terminationGracePeriodSecondsto 60+ seconds (not the default 30) to give active jobs time to finish - Resource limits are critical: CPU starvation causes lock renewal failures and stalled jobs — this is the #1 production issue with BullMQ
- Socket exhaustion: Each worker holds Redis connections; plan for
maxRetriesPerRequest: nullon all workers in large-scale deployments
A Dockerfile for a BullMQ worker should be minimal and follow security best practices:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine
ENV NODE_ENV=production
RUN addgroup -g 1001 -S worker && adduser -S -D -H -u 1001 -s /sbin/nologin -G worker worker
WORKDIR /app
COPY --from=builder --chown=worker:worker /app/node_modules ./node_modules
COPY --chown=worker:worker /app/dist ./dist
USER worker
EXPOSE 8080
CMD ["node", "dist/worker.js"]
BeeQueue: Simpler Start, More Manual Infrastructure
BeeQueue takes the opposite approach: a single Queue class handles both producing and consuming jobs. This is simpler to get started with, but it means you have to build deployment infrastructure yourself.
BeeQueue has no built-in graceful shutdown mechanism — you must implement draining manually:
import BeeQueue from 'bee-queue';
const queue = new BeeQueue('tasks', {
redis: { host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT) },
isWorker: true,
});
queue.process(async (job) => {
// your processor
});
async function shutdown(signal: string) {
console.log(`Received ${signal}, closing queue...`);
await queue.close(10_000); // timeout in ms
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
Key deployment considerations for BeeQueue:
- No health probe patterns exist in the library — you build them from
checkHealth() - No distinction between producer and consumer roles in the same process — the same
Queueobject does both - Scaling is "run more worker processes" — but no orchestration helpers exist
- Process supervision is entirely on you (PM2, systemd, Docker restart policies)
Deployment Comparison
| Aspect | BullMQ | BeeQueue |
|---|---|---|
| Graceful shutdown | worker.close() drains active jobs, respects SIGTERM |
queue.close(timeout) — manual implementation |
| Health probes | Well-documented pattern with /health/live, /health/ready |
Must build from checkHealth() |
| KEDA autoscaling | Documented Redis list-length trigger pattern | No documented pattern |
| Process separation | Queue and Worker are separate classes | Single Queue object handles both roles |
| Container-native design | Designed for it from the ground up | Works in containers, but library-agnostic |
BullMQ's architecture maps naturally to container orchestration. BeeQueue works fine in simple setups but requires more manual infrastructure work for production-grade deployments.
Section 2: Monitoring and Observability — What You Can See vs. What You Need
BullMQ: Multiple Observability Paths
BullMQ provides several built-in monitoring mechanisms that integrate with modern observability stacks.
Built-in metrics API:
const metrics = await queue.getMetrics('completed');
// {
// meta: { count: 1500, prevCount: 0 },
// data: [0, 45, 67, 123, ...] // per-minute job counts
// }
Prometheus export — official exportPrometheusMetrics() method:
import { exportPrometheusMetrics } from 'bullmq';
import promClient from 'prom-client';
app.get('/metrics', async (req, res) => {
res.set('Content-Type', promClient.register.contentType);
const bullmqMetrics = await exportPrometheusMetrics(queue);
const customMetrics = await promClient.register.metrics();
res.end(bullmqMetrics + '\n' + customMetrics);
});
Key metrics to monitor: bullmq_queue_waiting (backlog indicator), bullmq_queue_active (concurrency utilization), bullmq_queue_failed (alert threshold), bullmq_queue_completed_count (throughput), bullmq_queue_delayed_count.
OpenTelemetry integration (BullMQ 5.3+): BullMQ 5.3 introduced a telemetry interface with official OpenTelemetry support via the bullmq-otel package. This tracks the full job lifecycle — enqueue → wait → process → complete/fail — and works with Jaeger, Zipkin, Datadog APM, or any OTLP-compatible backend.
import { Queue, Worker } from 'bullmq';
import { BullMQOtel } from 'bullmq-otel';
const telemetry = new BullMQOtel('my-service');
const queue = new Queue('myQueue', {
connection,
telemetry,
});
const worker = new Worker('myQueue', async (job) => {
/* processor */
}, {
connection,
telemetry,
});
This gives you distributed traces showing which service enqueued a job, how long it waited, which worker processed it, and how long processing took — invaluable for root cause analysis when a user-facing request triggers an async job chain.
Real-time queue health check:
async function getQueueHealth(queue: Queue) {
const counts = await queue.getJobCounts();
const metrics = {
waiting: counts.waiting,
active: counts.active,
completed: counts.completed,
failed: counts.failed,
delayed: counts.delayed,
health: 'healthy',
};
// Alert thresholds
if (metrics.failed > 100) metrics.health = 'degraded';
if (metrics.waiting > 10000) metrics.health = 'backlogged';
if (metrics.active === 0 && metrics.waiting > 0) metrics.health = 'stalled-workers';
return metrics;
}
BeeQueue: Event-Driven Observability
BeeQueue has no built-in metrics API, no Prometheus export, no OpenTelemetry support. Observability is entirely event-driven:
import BeeQueue from 'bee-queue';
const queue = new BeeQueue('tasks', {
redis: { host: 'localhost' },
});
// All observability is custom
let processedCount = 0;
let failedCount = 0;
let lastJobProcessed: number | null = null;
queue.on('succeeded', (job) => {
processedCount++;
lastJobProcessed = Date.now();
});
queue.on('failed', (job, err) => {
failedCount++;
console.error(`Job ${job.id} failed:`, err.message);
});
// checkHealth() is the only built-in health method
queue.checkHealth((err, health) => {
if (err) {
console.error('Health check failed:', err);
return;
}
// health: { waiting, running, succeeded, failed }
console.log('Queue health:', health);
});
The Arena Dashboard — A Shared Tool
Arena is an open-source dashboard that supports Bull, BullMQ, and BeeQueue. It provides queue lists, job counts, job detail views, and retry/re-add actions. For BeeQueue, Arena is essentially the only monitoring GUI available.
const Arena = require('bull-arena');
const arena = Arena({
queues: [
{
name: 'tasks',
hostId: 'Redis Primary',
redis: { host: 'localhost', port: 6379 },
type: 'bee', // or 'bullmq'
},
],
});
app.use('/arena', arena);
Observability Comparison
| Capability | BullMQ | BeeQueue |
|---|---|---|
| Built-in metrics API | queue.getMetrics() — time-series data |
None |
| Prometheus export | Official exportPrometheusMetrics() |
Must build custom exporter |
| OpenTelemetry tracing | Official bullmq-otel package (5.3+) |
None |
| Job count query | getJobCounts() — full state breakdown |
checkHealth() — basic counts |
| Stalled job event | worker.on('stalled', ...) — built-in |
No stalled job concept |
| Job logs | queue.getJobLogs(jobId) |
Must implement externally |
| Grafana dashboard templates | Community dashboards (Grafana ID: 25072) | Must build from scratch |
BullMQ is fundamentally instrumented for observability. BeeQueue's observability is "what you build yourself" — fine for simple setups, but a significant operational gap at scale.
Section 3: Debugging Production Issues — Real Scenarios
BullMQ: The Stalled Job Nightmare
Stalled jobs are BullMQ's most common production issue. A job stalls when a worker fails to renew the lock within lockDuration (default 30 seconds). Common triggers:
- CPU starvation — container throttling or co-located heavy processes prevent the event loop from running
- Redis network partition or timeout
- Garbage collection pauses — Node.js GC pauses >200ms can trigger lock expiry
- Event loop blocking — synchronous operations or loops without
await
BullMQ recovers stalled jobs automatically (up to maxStalledCount, default 1). After exceeding that, the job moves to failed with a stalled reason.
const worker = new Worker('critical', processor, {
connection,
lockDuration: 60_000, // Increase for CPU-heavy jobs
stalledInterval: 30_000, // How often to check for stalled jobs
maxStalledCount: 3, // More lenient for flaky environments
});
worker.on('stalled', (jobId, previousState) => {
console.error(`STALLED JOB: ${jobId} (was ${previousState})`);
// Alert PagerDuty / Slack immediately
alertStalledJob(jobId);
});
worker.on('failed', (job, err) => {
if (err.message.includes('lock')) {
console.error(`Lock-related failure for job ${job?.id}:`, err.message);
}
});
Debugging checklist for stalled jobs:
- Check container CPU limits — are workers being throttled?
- Monitor event loop lag — use
blocked-atorloopbenchnpm packages - Review
lockDurationvs. actual job processing time - Implement manual lock extension for long-running jobs
- Verify Redis
maxmemory-policyisnoeviction - Confirm
maxRetriesPerRequest: nullon the worker's Redis connection
For long-running jobs, implement manual lock extension:
const worker = new Worker('long-tasks', async (job, token) => {
const extender = setInterval(async () => {
try {
await job.extendLock(token!, 30_000);
} catch (err) {
clearInterval(extender);
}
}, 25_000); // Extend 5s before expiry
try {
await processLongTask(job.data);
} finally {
clearInterval(extender);
}
}, { connection, lockDuration: 30_000 });
BullMQ: Redis Memory Blowup
By default, completed and failed jobs are stored forever in Redis. At scale, this causes memory exhaustion. The solution is to configure removeOnComplete and removeOnFail aggressively:
await queue.add('job', data, {
removeOnComplete: {
age: 24 * 3600, // Remove completed jobs after 24 hours
count: 1000, // Keep max 1000 completed jobs
},
removeOnFail: {
age: 7 * 24 * 3600, // Keep failed jobs for 7 days
count: 5000, // Keep max 5000 failed jobs
},
});
Redis memory management checklist:
- Set
maxmemory-policytonoevictionon the Redis server - Always configure
removeOnCompleteandremoveOnFail - Monitor Redis memory via
INFO memory - Schedule periodic
queue.clean()for old jobs as a safety net - Consider
maxlengthEventson the worker to limit event log entries
BeeQueue: Debugging Without Built-in Tooling
BeeQueue has no stalled job detection — if a worker dies mid-job, that job stays in "processing" limbo indefinitely. No built-in failedReason or stacktrace storage exists. Debugging relies entirely on application-level logging and manual Redis inspection.
Manual error wrapping for BeeQueue:
queue.process(async (job) => {
try {
return await processJob(job.data);
} catch (err) {
// Must manually log failure info
await redisClient.set(
`job:${job.id}:error`,
JSON.stringify({
message: (err as Error).message,
stack: (err as Error).stack,
timestamp: Date.now(),
data: job.data,
}),
'EX', 86400 // TTL: 24h
);
throw err;
}
});
BeeQueue's Redis key structure for manual debugging:
bee:queue:jobs:<id> → Job data (Redis hash)
bee:queue:waiting → List of waiting job IDs
bee:queue:active → Set of active job IDs
bee:queue:succeeded → List of successful job IDs
bee:queue:failed → List of failed job IDs
Redis commands for manual inspection:
# Check queue depth
LLEN bee:queue:waiting
# Check active jobs
SMEMBERS bee:queue:active
# Inspect a specific job
HGETALL bee:queue:jobs:<jobId>
# View recent failures
LRANGE bee:queue:failed 0 10
Debugging Comparison
| Scenario | BullMQ | BeeQueue |
|---|---|---|
| Worker crash mid-job | Auto-detected as stalled, re-queued or failed | Job stuck in "active" until manual cleanup |
| Failed job stack trace | Built-in job.stacktrace, job.failedReason |
Must implement manually |
| Job logs | job.log(), queue.getJobLogs() |
Not available |
| Lock renewal debugging | worker.on('stalled', ...), lock extension API |
No lock concept |
BullMQ's debugging facilities are the difference between a 5-minute investigation and a 2-hour fire drill.
Section 4: Redis Configuration for Production Queues
The One Setting You Cannot Get Wrong
Both BullMQ and BeeQueue require maxmemory-policy: noeviction on the Redis server. With any other policy (especially allkeys-lru or volatile-lru), Redis may evict queue data silently. BullMQ is particularly vulnerable because it uses TTL keys for locks and delays — evicting a lock key causes stalled jobs; evicting a delayed job key causes data loss.
# /etc/redis/redis.conf
maxmemory 2gb
maxmemory-policy noeviction
save 900 1
save 300 10
save 60 10000
appendonly yes
appendfsync everysec
Connection Configuration Differences
BullMQ actively distinguishes between producer and consumer connection behavior:
// Queue (producer) — fail fast
const producerConnection = new Redis({
host: process.env.REDIS_HOST,
retryStrategy: null, // Don't retry
enableOfflineQueue: false, // Don't buffer commands
maxRetriesPerRequest: 3, // Limited retries
connectTimeout: 5_000,
});
// Worker (consumer) — retry forever
const workerConnection = new Redis({
host: process.env.REDIS_HOST,
maxRetriesPerRequest: null, // Retry indefinitely
retryStrategy: (times) => Math.min(Math.exp(times) * 100, 20000),
enableOfflineQueue: true, // Buffer commands while reconnecting
});
Queue instances should fail fast so producers error immediately when Redis is down. Workers should retry forever so they reconnect when Redis comes back. BeeQueue doesn't support this distinction — the same connection config applies to both roles.
Persistence Comparison
| Setting | BullMQ | BeeQueue |
|---|---|---|
| Redis persistence needed? | Yes — AOF recommended | Yes — AOF recommended |
| Data loss window | ~1s with AOF everysec |
~1s with AOF everysec |
| Redis Sentinel/Cluster | Documented support | Supported but less tested |
| TLS (rediss://) | Full via IORedis | Full via IORedis |
Section 5: Upgrades and Production Maintenance
BullMQ: Well-Documented Upgrade Paths
BullMQ has explicit documentation for version upgrades with breaking change categories. BullMQ v4 → v5 introduced several changes:
- Connection is now mandatory (not just warned)
- Integer job IDs throw an error — must use string IDs
- Improved queue markers require all workers upgraded simultaneously
attemptsMadelogic changed — newattemptsStartedproperty- Default
maxStalledCountchanged to 1
Production upgrade strategy — pause, drain, upgrade, unpause:
// 1. Pause queues
await queue.pause();
// 2. Wait for active jobs to drain
let counts = await queue.getJobCounts();
while (counts.active > 0) {
await new Promise(r => setTimeout(r, 1000));
counts = await queue.getJobCounts();
}
// 3. Deploy new worker version (with upgraded BullMQ)
// 4. Unpause
await queue.resume();
Alternative strategy — new queue migration for zero-downtime upgrades:
// Run old and new queues side by side
const oldQueue = new Queue('tasks', { connection, ...oldOpts });
const newQueue = new Queue('tasks-v2', { connection, ...newOpts });
// Migrate jobs from old to new
async function drainAndMigrate() {
// Stop adding jobs to old queue at the producer level
// Replay any valuable jobs from old to new
const failed = await oldQueue.getJobs(['failed']);
for (const job of failed) {
await newQueue.add(job.name, job.data, job.opts);
}
}
BeeQueue: The Upgrade Situation
BeeQueue's last release on npm was v1.1.0 in 2021. As of mid-2026, the project is essentially in maintenance mode — no active development, no new features. There are no official migration guides for version upgrades, no breaking change documentation, and no changelog. Compatibility issues may arise with newer Redis versions.
Upgrade Comparison
| Aspect | BullMQ | BeeQueue |
|---|---|---|
| Release cadence | Active — multiple releases per year | Last release ~2021 |
| Changelog | Detailed with breaking change notes | Minimal |
| Migration docs | Official guide per major version | None |
| Pause/drain/upgrade | queue.pause() + queue.resume() |
No pause API |
| SOC 2 compliance | BullMQ team is SOC 2 Type II certified | No corporate entity |
Section 6: Operational Metrics — What to Measure, What to Alert On
BullMQ Metrics That Matter
| Metric | How to Get | Warning | Critical |
|---|---|---|---|
| Queue depth (waiting) | getJobCounts().waiting |
> 1,000 | > 10,000 |
| Failure rate (per min) | Metrics API or event counting | > 1% of total | > 5% of total |
| Stalled job count | worker.on('stalled', ...) |
> 1 | > 5 |
| Processing time p95 | Custom metric from timestamps | > 2x baseline | > 5x baseline |
| Event loop lag | loopbench package |
> 50ms | > 200ms |
| Redis memory usage | INFO memory |
> 70% of maxmemory | > 90% of maxmemory |
Build a prom-client histogram to track job processing duration:
import { exportPrometheusMetrics } from 'bullmq';
import promClient from 'prom-client';
const jobProcessingDuration = new promClient.Histogram({
name: 'bullmq_job_processing_duration_seconds',
help: 'Job processing duration in seconds',
labelNames: ['queue', 'jobName', 'status'],
buckets: [0.1, 0.5, 1, 2, 5, 10, 30, 60],
});
const activeJobsGauge = new promClient.Gauge({
name: 'bullmq_active_jobs',
help: 'Currently active jobs',
labelNames: ['queue'],
});
const stalledJobsCounter = new promClient.Counter({
name: 'bullmq_stalled_jobs_total',
help: 'Total stalled jobs',
labelNames: ['queue'],
});
BeeQueue Metrics
Fewer metrics available natively. The core question "Is it working?" is harder to answer without Prometheus integration:
const prometheusRegister = new promClient.Registry();
const beeQueueDepth = new promClient.Gauge({
name: 'bee_queue_waiting',
help: 'Jobs waiting in BeeQueue',
labelNames: ['queueName'],
registers: [prometheusRegister],
});
// Poll checkHealth periodically
setInterval(async () => {
queue.checkHealth((err, health) => {
if (err) return;
beeQueueDepth.set({ queueName: 'tasks' }, health.waiting);
});
}, 15_000);
// Express metrics endpoint
app.get('/metrics', async (req, res) => {
res.set('Content-Type', prometheusRegister.contentType);
res.end(await prometheusRegister.metrics());
});
Shared Operational Concerns
Both libraries require you to monitor:
- Redis server health (connected clients, memory, CPU, replication lag)
- Worker process health (alive, not OOM-killed)
- Job processing latency (are jobs being picked up promptly?)
- Throughput trends (processing faster than arrival?)
- Error rate and error diversity (is one job type causing most failures?)
Section 7: Why Operational Differences Matter for Your Queue Dashboard
QueueHub supports both BullMQ and BeeQueue — but the operational experience is fundamentally different for each.
For BullMQ users:
- Rich job metadata (stack traces, timestamps, progress, logs) flows naturally into QueueHub's job detail view
- Live worker view is powered by BullMQ's built-in worker tracking (
getWorkers()) - Agent tunneling for private Redis gives flexibility for TLS/production Redis setups
- The multi-backend support means you can manage BullMQ and BeeQueue queues from one dashboard
For BeeQueue users:
- Job detail is more limited — fewer fields populated by the library itself
- No worker tracking API — live worker view shows queue activity, not worker identity
- BeeQueue's
removeOnSuccess: truedefault means completed jobs disappear quickly — configure carefully for visibility - The simpler model means fewer moving parts to break, but less data to debug with
Operational recommendation:
- Choose BullMQ if you need rich debugging, Prometheus/Grafana integration, container-native deployment, and an active maintenance cycle
- Choose BeeQueue if your jobs are simple, short, high-throughput, and you have the operational maturity to build your own monitoring around Redis events
QueueHub smooths over some differences but cannot manufacture data the library doesn't produce — stalled job events, lock diagnostics, and OpenTelemetry traces only exist in BullMQ.
The Operational Reality in Three Points
-
BullMQ gives you a mature operational platform — health checks, Prometheus metrics, OpenTelemetry tracing, graceful shutdown, lock management, auto-stall recovery, documented upgrade paths, and active maintenance. You pay for this with complexity: more configuration, more concepts, more things that can need tuning.
-
BeeQueue gives you simplicity at the cost of operational tooling — it's easier to get started, harder to operate at scale. Monitoring, debugging, and upgrades require custom infrastructure. The library works fine; the operational story is what you make it.
-
Your monitoring platform can compensate for some gaps, but not all — QueueHub provides visibility into queue state for both libraries, but the underlying data availability differs. Stalled job events, OpenTelemetry traces, and lock diagnostics are BullMQ-only.
When you're evaluating which queue to standardize on, evaluate the operational story — not just the feature table. Run both through a deployment rehearsal. Test what happens when a worker pod crashes, when Redis goes down, when you need to upgrade the library version. Those experiences will tell you more than any README.
Ready to see how both queues behave in production? Try QueueHub to monitor your BullMQ and BeeQueue queues side by side — with live worker views, job detail inspection, and multi-backend support in a single dashboard. Set up takes minutes, and it works with local Redis, TLS, Valkey, AWS ElastiCache, and private Redis behind SSH tunnels.
Related Articles
Beyond Green and Red — Building an Intelligent Queue Health Score System for BullMQ
Stop treating every metric spike as an incident. Learn to build a composite queue health score that combines depth, latency, failures, workers, and Redis memory into a single 0–100 number with trend analysis, tier escalation, and alert gates.
BullMQ Scheduler Orchestration: Health Monitoring, Multi-Tenant Patterns & Performance at Scale
Moving beyond scheduler basics: building a programmatic CRUD service for BullMQ schedulers, detecting drift and dead schedulers with prevMillis and heartbeat TTLs, multi-tenant patterns with tenant-aware naming and queue partitioning, and understanding memory costs and enumeration performance at 10,000+ schedulers.
Testing and Development Workflows for BullMQ vs BeeQueue
A practical guide to testing job queue code with BullMQ and BeeQueue — test fixtures, producer and worker testing patterns, local development workflows, and CI integration.