·QueueHub Team·18 min read

BullMQ vs BeeQueue: Operational Realities in Production

bullmqbeequeueredisdevopsproductionmonitoringobservabilitykubernetes

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: 300 on 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: 1 prevents total worker loss during node drains
  • Grace period: Set terminationGracePeriodSeconds to 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: null on 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 Queue object 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:

  1. Check container CPU limits — are workers being throttled?
  2. Monitor event loop lag — use blocked-at or loopbench npm packages
  3. Review lockDuration vs. actual job processing time
  4. Implement manual lock extension for long-running jobs
  5. Verify Redis maxmemory-policy is noeviction
  6. Confirm maxRetriesPerRequest: null on 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-policy to noeviction on the Redis server
  • Always configure removeOnComplete and removeOnFail
  • Monitor Redis memory via INFO memory
  • Schedule periodic queue.clean() for old jobs as a safety net
  • Consider maxlengthEvents on 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:

  1. Connection is now mandatory (not just warned)
  2. Integer job IDs throw an error — must use string IDs
  3. Improved queue markers require all workers upgraded simultaneously
  4. attemptsMade logic changed — new attemptsStarted property
  5. Default maxStalledCount changed 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: true default 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

  1. 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.

  2. 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.

  3. 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