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.
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.
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.
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)
| Field | What it is |
|---|---|
data.values | Array of per-minute counts (oldest → newest). |
data.processedCount | Cumulative total for this metric. |
getMetrics 3rd/4th args | start, end indices for pagination (0 / -1 default). |
removeOnComplete/removeOnFail (Lesson 2) and use getJobs.
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);
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.
active with a lapsed lock, then it's moved. Don't go looking for a stalled set to count.
| Option | Default | Meaning |
|---|---|---|
stalledInterval | 30000ms (30s) | How often BullMQ scans for lapsed locks. |
maxStalledCount | 1 | After this many stalls the job is failed permanently ("job stalled more than allowable limit"). |
new Worker('Paint', processor, {
connection,
stalledInterval: 30_000,
maxStalledCount: 1,
});
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.
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.
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).
new Queue('email', {
connection,
defaultJobOptions: {
removeOnComplete: 1000, // keep last 1000 completed
removeOnFail: 5000, // keep last 5000 failed (for debugging)
},
});
| Value | Effect |
|---|---|
| number | Keep 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. |
true | Delete immediately. |
| Method | Removes |
|---|---|
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
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.
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');
metrics.maxDataPoints), read with queue.getMetrics('completed'|'failed'); per-minute buckets.queue.getJobCounts() is the snapshot — one call, all states incl. waiting-children and repeat.stalledInterval/maxStalledCount govern detection.removeOnComplete/removeOnFail (number, age-object, or true).clean() (surgical), drain() (waiting/delayed), obliterate() (everything, no undo).remove()/clean() throw; only obliterate({force}) overrides.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?
Or say "next" for Lesson 7: production — graceful shutdown, connection pooling, scaling workers.