·QueueHub Team·17 min read

Queue Monitoring Best Practices: A Complete Guide for BullMQ

BullMQmonitoringobservabilityRedisalerting

Introduction — The Silent Killer of Background Jobs

A web request fails and your monitoring system wakes you up at 2 AM with a page. A database query times out and your APM flags it within seconds. But a background job fails... and you find out three hours later from a customer complaint.

This asymmetry is the fundamental challenge of queue observability. Background jobs operate without user feedback, without HTTP responses, and often without anyone watching. Queues have become the connective tissue of modern applications — email delivery, push notifications, data processing pipelines, AI inference jobs, ETL workflows — yet the monitoring infrastructure around them is frequently an afterthought.

Without deliberate queue monitoring, a slow-growing backlog or a silent worker crash is invisible until it causes a production incident. A single dead worker can sit unnoticed for hours while jobs pile up, and by the time someone notices, you're dealing with hours of reprocessing and angry customers.

This guide covers what to monitor in your BullMQ queues, how to monitor it, and when to alert. We'll walk through four layers of observability — events, metrics, dashboards, and alerting — with production-ready code examples you can implement today.

Why queue monitoring is different from API monitoring — and why your existing APM might miss it entirely. Most APM tools monitor request/response cycles. Queues have no requests and no responses. They're asynchronous by nature, which means they require a fundamentally different observability approach.

The Monitoring Stack: Four Layers of Queue Observability

Think of queue monitoring as a pyramid with four layers. Each layer builds on the one below it:

        ┌─────────────┐
        │  Alerting   │  ← Notify when things go wrong
       ├─────────────┤
        │ Dashboards  │  ← Visualize trends over time
       ├─────────────┤
        │   Metrics   │  ← Collect quantitative data
       ├─────────────┤
        │   Events    │  ← Real-time job lifecycle signals
        └─────────────┘

Events feed metrics, metrics populate dashboards, and dashboards trigger alerts. If you skip the foundation — reliable event collection — everything above it is built on sand.

Layer 1: Real-Time Events — The Foundation

Before you can measure anything, you need to observe what's happening with your jobs in real time. BullMQ provides two complementary mechanisms for this.

Worker Events (In-Process)

Every BullMQ worker emits lifecycle events. These are the rawest signal available — they fire in the same process as your job handler:

import { Worker } from 'bullmq';

const worker = new Worker('email', async job => {
  // process job
}, { connection });

worker.on('completed', (job, result) => {
  // track completion, emit metric
});

worker.on('failed', (job, error) => {
  // increment failure counter, log error
});

worker.on('stalled', (jobId) => {
  // critical: job stalled means a worker died mid-processing
  console.warn(`Job ${jobId} stalled — possible worker crash`);
});

worker.on('error', (error) => {
  // connection or internal error — not a job failure
  alertWorkerHealth(error);
});

Best practice: Worker events are only visible in the same process. If your worker crashes, those listeners die with it. For cross-process visibility, you need QueueEvents.

QueueEvents — Cross-Process Visibility

QueueEvents uses Redis Pub/Sub to broadcast all job events across any process or machine. This means a dedicated monitoring service can observe everything without being coupled to any particular worker:

import { QueueEvents } from 'bullmq';

const queueEvents = new QueueEvents('email', {
  connection: {
    host: 'redis-01.internal',
    port: 6379,
    maxRetriesPerRequest: null,
  }
});

queueEvents.on('waiting', ({ jobId }) => {
  log.info(`Job ${jobId} added to queue`);
});

queueEvents.on('active', ({ jobId, prev }) => {
  log.info(`Job ${jobId} started processing (was ${prev})`);
});

queueEvents.on('completed', ({ jobId, returnvalue }) => {
  incrementCounter('jobs.completed', { queue: 'email' });
});

queueEvents.on('failed', ({ jobId, failedReason }) => {
  incrementCounter('jobs.failed', { queue: 'email' });
  trackError(failedReason);
});

queueEvents.on('stalled', ({ jobId }) => {
  alert('Job stalled — worker may have crashed');
});

queueEvents.on('drained', () => {
  log.info('Queue is empty — all jobs processed');
});

Key guidelines for QueueEvents:

  • Always use a separate Redis connection for QueueEvents (BullMQ recommends this to avoid connection contention)
  • Always close QueueEvents on shutdown to prevent connection leaks
  • Use QueueEvents for global monitoring services that are separate from your workers

BullMQ Telemetry (OpenTelemetry)

BullMQ 5.71+ has built-in OpenTelemetry support through the bullmq-otel package. This is a native adapter that produces end-to-end traces from job addition to completion — compatible with Jaeger, Zipkin, Datadog, and any OpenTelemetry backend:

import { Queue, Worker } from 'bullmq';
import { BullMQOtel } from 'bullmq-otel';

const queue = new Queue('pipeline', {
  connection,
  telemetry: new BullMQOtel('my-service'),
});

const worker = new Worker('pipeline', async job => {
  // job handler
}, {
  connection,
  telemetry: new BullMQOtel('my-service'),
});

This is a zero-overhead addition — the bullmq-otel package is separate, so you only pay for telemetry when you explicitly enable it. The true power of OpenTelemetry for queue monitoring is trace correlation: you can trace a single job from the moment it was enqueued, through Redis storage, into the worker, across any downstream API calls, and back to completion.

SEO angle: BullMQ OpenTelemetry integration lets you trace jobs across services without monkey-patching or custom instrumentation.

Layer 2: Metrics — Quantifying Queue Health

Events tell you what just happened. Metrics tell you what's been happening over time. This is where you move from reactive debugging to proactive observability.

BullMQ Built-In Metrics (Per-Minute Data Points)

BullMQ workers can track completed and failed jobs per minute with minimal memory overhead — approximately 120KB per queue for two weeks of data:

import { Worker, MetricsTime } from 'bullmq';

const worker = new Worker('Paint', async job => {
  // process
}, {
  connection,
  metrics: {
    maxDataPoints: MetricsTime.ONE_WEEK * 2,  // 2 weeks of per-minute data
  },
});

You can query these metrics later:

import { Queue, MetricsTime } from 'bullmq';

const queue = new Queue('Paint', { connection });
const metrics = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK * 2);
// Returns: { data: number[], count: number, meta: { count, prevTS, prevCount } }

Best practice: Use the same metrics configuration on ALL workers for a given queue to ensure consistent data collection.

Key Queue Metrics to Track

Not all metrics are created equal. Here are the ones that matter most:

Metric How to Get It What It Tells You
Waiting Count queue.getWaitingCount() Backlog depth — how many jobs are waiting to be processed
Active Count queue.getActiveCount() Currently processing jobs — should match worker concurrency
Failed Count queue.getFailedCount() Total failures — track rate, not just absolute number
Delayed Count queue.getDelayedCount() Scheduled/delayed jobs pending
Completed Count queue.getCompletedCount() Total throughput — use for rate calculations
Worker Count Custom (track worker heartbeats) Are workers alive? Is the expected count running?
Job Processing Rate completed jobs / time window Throughput — rate drops indicate problems
Failure Rate failed jobs / total jobs Error rate — spikes indicate bugs or dependency failures
Avg Processing Time Custom (track job duration) Performance regression detection
p95/p99 Processing Time Custom histogram Long-tail latency — slowest jobs
Oldest Job Age Custom (scan waiting jobs) Stale job detection — jobs stuck in queue
Stalled Jobs queueEvents.on('stalled') Worker crashes — jobs with expired locks

Pro tip: Poll these metrics every 5–30 seconds for a monitoring dashboard. Less than 5 seconds is overkill and creates unnecessary Redis load; more than 60 seconds misses short spikes that can indicate transient issues.

Redis-Level Metrics (The Infrastructure Layer)

BullMQ queues are Redis-backed by design. Redis health IS queue health. When Redis struggles, your queues struggle:

Redis Metric Why It Matters Warning Threshold
used_memory Redis memory: queue data accumulates > 80% of maxmemory
connected_clients Worker & QueueEvents connections Drops indicate crashes
instantaneous_ops_per_sec Throughput proxy Sudden changes signal issues
reject_connections Redis at capacity Critical — queue becomes unavailable
latest_fork_usec Fork time for BGSAVE Long forks block Redis
Hit Rate (keyspace_hits / misses) Cache efficiency Low hit rate = scaling issue

If you're using Queue Hub (QueueHub), Redis connection details including TLS, Valkey, and ElastiCache support are surfaced alongside queue metrics, so you see both layers in one place.

Layer 3: Dashboards — Visualizing Queue Health

A well-designed dashboard transforms raw metrics into at-a-glance understanding. Here's what a production queue dashboard should show.

The Essential Queue Dashboard Panels

1. Queue Overview (top row — one stat per queue) — Waiting, Active, Failed, and Delayed counts, color-coded: green (normal), yellow (warning), red (critical).

2. Throughput Chart (time series) — Jobs completed per minute overlaid with jobs failed per minute on the same axis. This ratio at a glance tells you whether your system is healthy.

3. Queue Depth Over Time (time series) — Waiting + Delayed count over the last hour or day. This is your backlog trend, and it's the single most important time-series for detecting gradual degradation.

4. Processing Time Distribution (heatmap or histogram) — p50, p95, and p99 processing times overlaid. A sudden jump in p95 without a corresponding p50 jump points to a specific class of slow jobs.

5. Failure Breakdown (table or pie) — Top error messages with counts, failed job names, and failure reasons with stack traces.

6. Worker Health (stat panel) — Active workers count, worker concurrency, and last heartbeat timestamp.

Prometheus + Grafana (DIY Approach)

BullMQ has native Prometheus metrics export. This is the most popular DIY approach for teams that already run Prometheus and Grafana:

import { Queue } from 'bullmq';
import http from 'http';

const queue = new Queue('my-queue', { connection });

const server = http.createServer(async (req, res) => {
  if (req.url === '/metrics' && req.method === 'GET') {
    const metrics = await queue.exportPrometheusMetrics();
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end(metrics);
  }
});

server.listen(9090);

The output includes standard Prometheus metrics:

# HELP bullmq_job_count Number of jobs in the queue by state
# TYPE bullmq_job_count gauge
bullmq_job_count{queue="my-queue", state="waiting"} 5
bullmq_job_count{queue="my-queue", state="active"} 3
bullmq_job_count{queue="my-queue", state="completed"} 12
bullmq_job_count{queue="my-queue", state="failed"} 2

Add global labels for multi-environment filtering:

const metrics = await queue.exportPrometheusMetrics({
  env: 'production',
  region: 'us-east-1',
});

Grafana dashboard: The community "BullMQ Overview" dashboard (ID: 25072 on Grafana Labs) is a ready-to-import dashboard that works out of the box with the Prometheus endpoint above.

Custom Prometheus Metrics Collector

For richer metrics — particularly p95/p99 processing time histograms and per-job-name breakdowns — you'll want a custom collector:

import { Counter, Gauge, Histogram, Registry } from 'prom-client';

const register = new Registry();

const jobsProcessed = new Counter({
  name: 'bullmq_jobs_processed_total',
  help: 'Total jobs processed',
  labelNames: ['queue', 'status'],
  registers: [register],
});

const jobsActive = new Gauge({
  name: 'bullmq_jobs_active',
  help: 'Currently active jobs',
  labelNames: ['queue'],
  registers: [register],
});

const jobDuration = new Histogram({
  name: 'bullmq_job_duration_seconds',
  help: 'Job processing duration',
  labelNames: ['queue', 'job_name'],
  buckets: [0.1, 0.5, 1, 2, 5, 10, 30, 60, 120],
  registers: [register],
});

This custom collector lets you answer questions like "which job type is causing the p95 slowdown?" — information that the built-in exportPrometheusMetrics() doesn't provide.

Layer 4: Alerting — Knowing When to Wake Up

Metrics and dashboards are useful during business hours. Alerting is what protects your sleep. The goal is to be notified about problems before customers are.

Essential Alerts (Copy-Paste Ready)

Here are the alerts every production BullMQ deployment should have:

Alert Condition Severity Why
High Queue Depth waiting > 1000 for > 5 min Warning Backlog growing — investigate before users notice
Critical Queue Depth waiting > 5000 for > 2 min Critical Immediate action — staging or downstream issue
High Failure Rate failureRate > 5% for > 5 min Warning Code or dependency issue
Critical Failure Rate failureRate > 20% for > 2 min Critical Bad deploy or downstream outage
No Processing waiting > 0 AND active === 0 AND !paused Critical Workers are DEAD — highest priority alert
Slow Processing p95 processing time > 60s Warning Performance regression
Stale Jobs oldestJobAge > 1 hour Warning Jobs stuck — possible worker starvation
Worker Drop worker count < expected min Critical Workers crashed — processing flatlining
Stalled Jobs Detected stalled count > 0 Critical Worker crash mid-job — data loss risk
Redis Memory used_memory > 80% of max Warning OOM or eviction risk
Redis Down Connection failed Critical All queue operations stop

Implementing a Health Check Endpoint

A simple health check endpoint can serve as the foundation for your external monitoring (Pingdom, Datadog, StatusCake, etc.):

import express from 'express';

const app = express();

app.get('/queue-health', async (req, res) => {
  const queues = ['email', 'notifications', 'data-pipeline', 'reports'];
  const health = await Promise.all(queues.map(async (name) => {
    const queue = new Queue(name, { connection });
    const [waiting, active, failed, delayed] = await Promise.all([
      queue.getWaitingCount(),
      queue.getActiveCount(),
      queue.getFailedCount(),
      queue.getDelayedCount(),
      queue.isPaused(),
    ]);
    return {
      queue: name,
      healthy: active > 0 || waiting === 0,
      waiting,
      active,
      failed,
      delayed,
      failureRate: active + waiting > 0
        ? (failed / (active + waiting + failed)) * 100
        : 0,
    };
  }));

  const allHealthy = health.every(q => q.healthy);
  res.status(allHealthy ? 200 : 503).json({
    status: allHealthy ? 'healthy' : 'degraded',
    queues: health,
  });
});

Best practice: Integrate this endpoint with your existing incident response system (PagerDuty, OpsGenie, Slack) so queue health is part of your standard monitoring, not a separate concern.

Prometheus Alerting Rules

If you're using the Prometheus approach, here are the alerting rules that correspond to the table above:

groups:
  - name: bullmq_alerts
    rules:
      - alert: BullMQHighFailureRate
        expr: rate(bullmq_job_count{state="failed"}[5m]) / rate(bullmq_job_count{state=~"completed|failed"}[5m]) > 0.05
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "BullMQ failure rate > 5% on {{ $labels.queue }}"

      - alert: BullMQQueueDepthCritical
        expr: bullmq_job_count{state="waiting"} > 5000
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Queue {{ $labels.queue }} has {{ $value }} waiting jobs"

      - alert: BullMQNoActiveWorkers
        expr: bullmq_job_count{state="waiting"} > 0 AND ON(queue) bullmq_job_count{state="active"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Queue {{ $labels.queue }} has waiting jobs but no active workers"

Proactive vs. Reactive Monitoring

Reactive Monitoring (Catching Fires)

Most teams start with reactive monitoring: alert when a queue backs up, investigate when users report slowness. The problem is that by the time these alerts fire, you're already in an incident.

Proactive Monitoring (Preventing Fires)

Proactive monitoring catches problems before they become incidents:

Proactive Practice What It Catches
Heartbeat jobs Silent worker death, queue misconfiguration
Trend-based alerts Gradual backlog growth before it's critical
Baseline deviation Throughput drop from a new deploy
Worker count monitoring Worker crash after deploy (autorun: false bug)
Job duration regression Code changes that slow processing
Redis trend monitoring Memory growth before OOM

The Heartbeat Pattern (Most Important Proactive Tool)

The single most reliable queue health check is a heartbeat job — a simple ping that runs every few minutes and proves your system is working end-to-end:

// Scheduler (separate process or cron)
import { Queue, QueueScheduler } from 'bullmq';

const heartbeatQueue = new Queue('__heartbeat', { connection });

async function sendHeartbeat() {
  await heartbeatQueue.add('ping', {
    timestamp: Date.now(),
    host: os.hostname(),
  }, {
    removeOnComplete: { age: 3600 * 24 },
  });
}

// Worker that processes heartbeats
const heartbeatWorker = new Worker('__heartbeat', async (job) => {
  const latency = Date.now() - job.data.timestamp;
  await fetch(`https://heartbeat.internal/queue-alive?latency=${latency}`);
  return { ok: true, latency };
}, { connection });

A heartbeat failure catches all of these scenarios:

  • Worker process crashed or never started
  • Redis connection lost
  • Queue misconfigured after deploy
  • Event loop blocked (visible as high latency values)

Common Pitfalls and Anti-Patterns

Anti-Pattern Why It's Bad Better Approach
Only monitoring queue depth A flat queue can hide dead workers — no new jobs being added, all quiet Always pair depth with active worker count
Alerting on absolute counts only 100 waiting jobs is fine for a high-volume queue, critical for a low-volume one Use baselines, rate-based alerts, and per-queue thresholds
Ignoring delayed job counts Delayed jobs hide in "waiting" if you only check getWaitingCount() Always check waiting + delayed for true backlog
No cleanup policy Jobs accumulate forever → Redis OOM Always set removeOnComplete and removeOnFail
Using a single Redis connection for everything Connection pool exhaustion, performance bottlenecks Separate connections for Queue, Worker, QueueEvents, and monitoring
Only checking metrics when something breaks No baseline to compare against Continuously collect metrics, store for trend analysis
Missing maxRetriesPerRequest: null Redis connection issues can stall the entire queue Always set this in production BullMQ configurations

Production Checklist

Minimum Viable Monitoring (15 minutes)

  • Expose /queue-health endpoint returning queue depths + worker status
  • Set up a heartbeat job that pings a health URL every 5 minutes
  • Create a Slack alert for: waiting > 0 AND active === 0 (dead workers)
  • Configure removeOnComplete and removeOnFail on all jobs

Good Monitoring (1 hour)

  • Poll queue metrics every 30 seconds and store for trend analysis
  • Set up a dashboard with throughput, depth, failure rate, and processing time
  • Alert on: high queue depth (>5000), high failure rate (>5%), stalled jobs
  • Enable BullMQ built-in metrics on all workers
  • Configure Prometheus endpoint for BullMQ

Comprehensive Monitoring (half-day)

  • Full Prometheus + Grafana stack with BullMQ dashboard
  • Per-queue alert thresholds with dynamic baselines
  • Worker count monitoring with min-threshold alerts
  • Job duration p95/p99 alerts per job class
  • Redis memory and connection monitoring
  • OpenTelemetry tracing with bullmq-otel across all services
  • PagerDuty integration for critical alerts
  • Multi-environment monitoring (dev/staging/prod)
  • Anomaly detection on throughput and failure rate trends

Conclusion

Queue monitoring is not optional — background jobs fail silently, and without observability you're flying blind. The four-layer model — Events → Metrics → Dashboards → Alerts — gives you a complete picture of your queue health.

BullMQ provides excellent built-in tools: per-minute metrics tracking, native Prometheus export, and OpenTelemetry support through bullmq-otel. You can build a comprehensive monitoring stack with Prometheus and Grafana, or use a managed solution like QueueHub (Queue Hub) that collapses all four layers into a single interface.

The key is to choose proactive monitoring over reactive. Don't wait for a customer complaint to discover your email queue has been backing up for three hours. Set up heartbeat jobs. Monitor worker counts. Track processing time percentiles. Configure alerts that fire before incidents happen.

Stop flying blind. Whether you roll your own with Prometheus + Grafana or connect QueueHub in minutes, the cost of not monitoring your queues is an incident waiting to happen.

Useful resources:

Ready to see your queues in real time? Try QueueHub for free — connect your Redis instance in minutes and get live monitoring, historical metrics, and configurable alerts without building any infrastructure.

Related Articles