Tied to mission: a worker that crashes on the first transient error is useless. This lesson turns "fire and hope" into "fire, retry smart, and surface the unfixable." Builds directly on Lesson 1.
BullMQ marks a job failed in exactly two cases:
Error (not a string, not a literal — an actual Error object).maxStalledCount.Error objects. Throwing a string (throw 'bad') corrupts BullMQ's internal bookkeeping. There's an ESLint rule no-throw-literal — turn it on.
| Option | What it means |
|---|---|
attempts | Total tries including the first. attempts: 3 = 1 initial + 2 retries. |
backoff | Delay strategy between retries. Omit → retried instantly (usually bad). |
| Type | Formula | Use when |
|---|---|---|
fixed | constant delay ms | Rate-limited external API with a fixed cooldown. |
exponential | 2^(attemptsMade-1) * delay ms | Default choice. Flaky network, transient DB errors, third-party downtime. |
await queue.add('charge-card', { orderId: 99 }, {
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
});
Timeline for delay: 1000, all attempts failing:
attempt 1 → fail → wait 1000ms
attempt 2 → fail → wait 2000ms
attempt 3 → fail → wait 4000ms
attempt 4 → fail → wait 8000ms
attempt 5 → fail → FAILED (permanent), enters failed set
Imagine 100 jobs fail at the same instant because a downstream API blipped. Without jitter, all 100 retry on the same exponential schedule — they hit the API in a synchronized wave and fail again. Jitter randomises each delay to spread the load.
backoff: { type: 'exponential', delay: 1000, jitter: 0.5 }
jitter is 0–1. 0.5 means "anywhere between 50% and 100% of the computed delay." Use 0.5 as a sane default whenever many jobs can fail together.
const queue = new Queue<EmailPayload>('email', {
connection,
defaultJobOptions: {
attempts: 5,
backoff: { type: 'exponential', delay: 2000, jitter: 0.5 },
removeOnComplete: 1000, // keep last 1000 completed
removeOnFail: 5000, // keep last 5000 failed for debugging
},
});
// override for the one job that needs more tries
await queue.add('send-critical', data, { attempts: 20 });
removeOnComplete / removeOnFail are not optional in prod. If you don't set them, Redis grows forever and eventually OOMs. Use a count (keep last N) or true (delete immediately).
The processor can behave differently based on how many times it has already tried. job.attemptsMade starts at 1 on the first run.
async (job: Job) => {
try {
await callFlakyApi(job.data.payload);
} catch (e) {
if (job.attemptsMade < job.opts.attempts) {
console.log(`attempt ${job.attemptsMade} failed, will retry:`, (e as Error).message);
}
throw e; // always re-throw so BullMQ handles the retry
}
}
Define the strategy on the worker (not the queue), then reference it by name when adding jobs.
new Worker<EmailPayload>('email', processor, {
connection,
settings: {
backoffStrategy: (attemptsMade: number) => {
// cap at 60s, with mild jitter
const base = Math.min(60_000, 2 ** (attemptsMade - 1) * 1000);
return base + Math.random() * 1000;
},
},
});
await queue.add('x', data, {
attempts: 8,
backoff: { type: 'custom' }, // uses backoffStrategy above
});
Return values are special: 0 → push to back of waiting; -1 → fail immediately, no more retries.
BullMQ has no built-in DLQ. The standard pattern: when a job exhausts its retries, listen for the final failure and re-enqueue it onto a separate queue for human inspection, alerting, or replay.
import { Queue, QueueEvents } from 'bullmq';
const dlq = new Queue('email-dead', { connection });
const qe = new QueueEvents('email', { connection });
qe.on('failed', async ({ jobId, failedReason, prev }) => {
// fires on EVERY failure, so only act on the final one
if (prev !== 'active') return;
const job = await emailQueue.getJob(jobId);
if (!job) return;
// final attempt? attemptsMade === attempts means no more retries
if (job.attemptsMade >= (job.opts.attempts ?? 1)) {
await dlq.add(job.name, {
originalId: job.id,
data: job.data,
reason: failedReason,
attempts: job.attemptsMade,
failedAt: new Date().toISOString(),
});
// optional: await job.remove(); // keep main queue clean
}
});
failed event fires on every attempt. Many newcomers alert on the first failure and spam Slack. Check prev === 'active' and attemptsMade >= attempts before treating it as terminal.
While a job is active, the worker renews a lock every stalledInterval ms. If the process crashes, or blocks the event loop (CPU-bound work, infinite loop), the lock isn't renewed. After stalledInterval BullMQ notices, moves the job back to waiting, and another worker picks it up — unless it has stalled maxStalledCount times, in which case it goes to failed.
new Worker('email', processor, {
connection,
stalledInterval: 30_000, // default 30s — check locks this often
maxStalledCount: 1, // default 1 — after this many stalls, fail it
});
Lesson: keep processors async and I/O-bound. If you need to do CPU-heavy work (image resize, crypto), use sandboxed processors (separate threads) — covered in a later lesson.
await queue.drain(); // remove waiting + delayed (not active/completed/failed)
await queue.drain(true); // also delayed
await queue.clean(60_000, 1000, 'failed'); // remove failed older than 60s, max 1000
await queue.obliterate(); // wipe everything — no undo, queue gone
attempts + backoff are the retry engine; exponential + jitter is the default.Error objects, never strings.removeOnComplete/removeOnFail or Redis grows unbounded.failed event fires per-attempt; check attemptsMade before alerting.1. With attempts: 4, how many retries after the first try?
2. Exponential backoff delay 1000, attempt 4 fails. Next wait?
3. What must you throw from a processor?
4. Why add jitter: 0.5 to exponential backoff?
5. Where do you set retry defaults for every job?
6. What happens if you omit removeOnFail?
7. When does the failed event fire?
8. Is there a built-in dead-letter queue in BullMQ?
9. What causes a job to become stalled?
10. What does queue.obliterate() do?
Or say "next" for Lesson 3: delayed & scheduled (cron) jobs.