Tied to mission: this is the foundation every later lesson (retries, scheduling, rate-limiting, prod ops) builds on. Skip it and everything downstream is memorisation, not understanding.
Some work shouldn't happen in the request path: sending email, resizing an image, calling a flaky third-party API, generating a PDF. Do it inline and your HTTP request takes 5 seconds; fail it and the user has to start over. A queue moves that work out of the request: you drop a small message ("send the welcome email to user 42") into Redis, return immediately, and a separate worker process picks it up and does the work — retrying on failure, scaling to many workers, surviving crashes.
BullMQ is a Node.js library that does this on top of Redis with three primitives:
| Primitive | Role |
|---|---|
Queue | Producer. Adds jobs. Thin wrapper over a Redis list. |
Worker | Consumer. Pulls jobs and runs your processor function. |
Job | The unit of work. Has id, name, data, and a lifecycle. |
Producer ──add()──▶ Queue (Redis) ──pull──▶ Worker ──▶ completed | failed
node -v).docker run -p 6379:6379 -d redis.redis-cli ping → PONG.mkdir bullmq-101 && cd bullmq-101
npm init -y
npm install bullmq ioredis typescript tsx @types/node -D
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext
maxRetriesPerRequest: null. BullMQ issues long-running blocking reads against Redis. If ioredis has its default (3 retries), the moment Redis blips your workers throw Connection is closed. Set it to null every time. This is the #1 BullMQ gotcha.
// connection.ts
import IORedis from 'ioredis';
export const connection = new IORedis('redis://localhost:6379', {
maxRetriesPerRequest: null,
});
Reuse this one connection across your Queue and Worker (BullMQ multiplexes). Passing a string URL or bare { host, port } also works, but a shared instance is the safe default.
The job data is your contract between producer and consumer. Type it once, share it, never lose it.
// types.ts
export interface EmailPayload {
to: string;
subject: string;
body: string;
}
// producer.ts
import { Queue } from 'bullmq';
import { connection } from './connection.js';
import type { EmailPayload } from './types.js';
const emailQueue = new Queue<EmailPayload>('email', { connection });
const job = await emailQueue.add('send-welcome', {
to: '[email protected]',
subject: 'Welcome aboard',
body: 'Glad to have you.',
});
console.log('Enqueued', job.id);
new Queue('email') upserts a small meta-key in Redis. If the queue already exists, you pick it up where it left off. The generic <EmailPayload> makes add reject the wrong shape at compile time.
// worker.ts
import { Worker, Job } from 'bullmq';
import { connection } from './connection.js';
import type { EmailPayload } from './types.js';
new Worker<EmailPayload>(
'email',
async (job: Job) => {
console.log(`Sending to ${job.data.to}`);
await job.updateProgress(50);
await fakeSmtpSend(job.data.to);
return { ok: true, at: new Date().toISOString() };
},
{ connection, concurrency: 5 },
);
async function fakeSmtpSend(to: string) {
await new Promise(r => setTimeout(r, 200));
console.log(` ✓ delivered to ${to}`);
}
Rules that matter now:
completed, return value stored on job.returnvalue.failed and (in later lessons) gets retried.concurrency: 5 means up to 5 jobs run in parallel inside this one worker process.error handler — without one, Node can kill your worker on an emitted error.Open two terminals:
# Terminal A — keep the worker running
npx tsx worker.ts
# Terminal B — fire one job
npx tsx producer.ts
You should see the worker log "Sending to…" and "✓ delivered", within a couple hundred ms of running the producer.
redis-cli
> KEYS bull:email:*
> LRANGE bull:email:wait 0 -1 # jobs waiting to be picked up
> LRANGE bull:email:active 0 -1 # jobs a worker is currently running
> LRANGE bull:email:completed 0 -1 # finished jobs (until cleaned)
Add a job, then look at bull:email:wait before the worker grabs it. Now you see what "stored in Redis" means.
waiting → active → completed (or failed).data is your producer↔consumer contract — type it with generics.maxRetriesPerRequest: null is non-negotiable on the ioredis connection.completed/failed; QueueEvents listens across all workers.Pick the best option. Each set of options is deliberately the same length so no answer is given away by shape.
1. Which class adds a job to a queue?
2. Where does BullMQ store jobs?
3. What does a worker do when the processor throws?
4. Which ioredis option is mandatory?
5. How do you make add() type-safe?
6. What does queue.add('x', {}) return?
7. Where is a completed job's return value?
8. What does concurrency: 5 mean?
9. How do you listen across all workers?
10. Why must workers register an error handler?