Lesson 6 — Metrics, Stalled Jobs & Cleanup

Goal: See what your queue is doing (metrics), detect when work silently dies (stalled jobs), and stop Redis from filling up forever (cleanup). These three are the difference between "queue runs" and "queue is operable."

Tied to mission: "monitor queue health" is an explicit success criterion — and the 3am page usually comes from a stalled job nobody noticed or a Redis OOM nobody prevented. Builds on Lesson 2 (stalled basics) and Lesson 1.


Part A — Metrics: throughput over time

BullMQ keeps lightweight time-series of completed and failed jobs, bucketed per one-minute interval. Off by default; you enable it on the worker and read it from the queue.

Enable

import { Worker, MetricsTime } from 'bullmq';

new Worker('Paint', processor, {
  connection,
  metrics: { maxDataPoints: MetricsTime.ONE_WEEK * 2 },  // keep ~2 weeks
});

maxDataPoints = how many 1-minute buckets to retain. MetricsTime.ONE_WEEK is a convenience constant. The storage cost is tiny — a counter per bucket.

Read

const completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK * 2);
// { meta: {...}, data: { values: number[], processedCount: number }, count, start, end }

const failed = await queue.getMetrics('failed');   // default start=0, end=-1 (all)
FieldWhat it is
data.valuesArray of per-minute counts (oldest → newest).
data.processedCountCumulative total for this metric.
getMetrics 3rd/4th argsstart, end indices for pagination (0 / -1 default).
Metrics are a sampling, not a log. They're for charts ("throughput last hour", "failure spike at 14:00"), not for auditing individual jobs. For per-job data, keep jobs with removeOnComplete/removeOnFail (Lesson 2) and use getJobs.

Snapshot counts — the monitoring bread-and-butter

const counts = await queue.getJobCounts();
// { waiting, active, completed, failed, delayed,
//   prioritized, 'waiting-children', paused, repeat }

This is what you wire into a /health endpoint or a dashboard: a backlog climbing in waiting while completed flattens = workers are stuck. All available states: completed, failed, delayed, active, wait/waiting, waiting-children, prioritized, paused, repeat.

// list jobs in a state, paginated
const failed = await queue.getJobs(['failed'], 0, 50);
const job = await queue.getJob(id);

Part B — Stalled jobs: when a worker vanishes

While a job is active, the worker renews a lock by heartbeat. If the worker process crashes, gets OOM-killed, or blocks the Node event loop too long, the lock lapses. BullMQ then moves the job back to waiting so another worker can grab it — or, if it's stalled too many times, fails it.

There is no "stalled" state — only a "stalled" event. A stalled job is momentarily active with a lapsed lock, then it's moved. Don't go looking for a stalled set to count.

The two knobs (on the Worker)

OptionDefaultMeaning
stalledInterval30000ms (30s)How often BullMQ scans for lapsed locks.
maxStalledCount1After this many stalls the job is failed permanently ("job stalled more than allowable limit").
new Worker('Paint', processor, {
  connection,
  stalledInterval: 30_000,
  maxStalledCount: 1,
});

Listen for it

worker.on('stalled', (jobId) => {
  console.warn(`job ${jobId} stalled and was re-queued`);
});

Alert on this in prod. A stalled job means a worker died mid-flight — if you see a steady stream of stalls, you have CPU-bound work blocking the event loop.

The fix for chronic stalls is architectural, not a knob. Raising maxStalledCount or lengthening stalledInterval hides the symptom. The real cause is almost always synchronous CPU work (image resize, crypto, JSON.parse of huge blobs) starving the heartbeat. Move it to a sandboxed processor (separate worker thread) so the event loop stays responsive.

Part C — Cleanup: don't let Redis grow forever

Every completed/failed job is a Redis key. Without bounds, Redis grows until it OOMs — and BullMQ won't save you. There are two layers: automatic (per-job options) and manual (queue methods).

Layer 1 — automatic retention (set this always)

new Queue('email', {
  connection,
  defaultJobOptions: {
    removeOnComplete: 1000,   // keep last 1000 completed
    removeOnFail: 5000,       // keep last 5000 failed (for debugging)
  },
});
ValueEffect
numberKeep the last N jobs in that state.
{ age: seconds, count: n }Combined: keep up to N, but none older than age.
{ age: seconds }Time-based only.
trueDelete immediately.
This is not optional in prod. It's the single most common BullMQ operational failure. Set it as a queue default and override per job only when you need to keep something longer.

Layer 2 — manual cleanup methods

MethodRemoves
job.remove()One specific job.
queue.clean(graceMs, count, state)Up to count jobs in state older than graceMs.
queue.drain()Waiting + delayed (NOT active/completed/failed).
queue.drain(true)Above, plus delayed jobs explicitly.
queue.obliterate({ force })Everything — the queue is gone. No undo.
// surgical: remove failed jobs older than 1 hour, max 1000
await queue.clean(3_600_000, 1000, 'failed');

// nuke from orbit (CI / test only)
await queue.obliterate();
await queue.obliterate({ force: true });  // even if jobs are active
Active (locked) jobs can't be removed. job.remove() or clean() on an active job throws. Only obliterate({ force: true }) overrides — and even then the in-flight processor keeps running; you've just orphaned its result.

Full picture — an observable, self-cleaning worker

import { Queue, Worker, MetricsTime } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis({ maxRetriesPerRequest: null });

const email = new Queue('email', {
  connection,
  defaultJobOptions: {
    removeOnComplete: { age: 86_400, count: 2000 },  // ≤2000, none older than 24h
    removeOnFail:    { age: 604_800, count: 5000 },  // ≤5000, none older than 7d
  },
});

const worker = new Worker('email', processor, {
  connection,
  concurrency: 5,
  stalledInterval: 30_000,
  maxStalledCount: 1,
  metrics: { maxDataPoints: MetricsTime.ONE_WEEK * 2 },
});

worker.on('stalled', (id) => metrics.increment('bullmq.stalled', { queue: 'email' }));

// health endpoint / cron:
//   const c = await email.getJobCounts();
//   const m = await email.getMetrics('completed');
Tangible win. Your queue now reports its own health (counts + metrics), self-heals when a worker dies (stalled → re-queued, with an event to alert on), and self-cleans (bounded retention). You can go on call.

What you just learned


🧠 Quiz — 10 questions

1. Where do you enable metrics — queue or worker?

2. What time bucket do metrics use?

3. What does queue.getJobCounts() return?

4. Is there a "stalled" job state?

5. Default value of maxStalledCount?

6. A stalled job is moved back to which state?

7. The usual root cause of chronic stalls?

8. What happens if you omit removeOnComplete?

9. Which method removes everything with no undo?

10. What happens if you remove an active (locked) job?


📚 Primary sources

Metrics · Stalled · Removing Jobs · Getters (official). Also: Sandboxed Processors.

📎 Reference

Observability & Cleanup Cheatsheet — one-screen lookup for metrics, stalled knobs, and the cleanup matrix.

💬 Ask me next

Or say "next" for Lesson 7: production — graceful shutdown, connection pooling, scaling workers.