Lesson 1 — Your First BullMQ Job

Goal: Produce a typed job from one file, consume it in another, see it complete in Redis. By the end you will have run a real BullMQ pipeline end-to-end in TypeScript.

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.

Why BullMQ exists

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:

PrimitiveRole
QueueProducer. Adds jobs. Thin wrapper over a Redis list.
WorkerConsumer. Pulls jobs and runs your processor function.
JobThe unit of work. Has id, name, data, and a lifecycle.
Producer ──add()──▶ Queue (Redis) ──pull──▶ Worker ──▶ completed | failed

Prerequisites (5 minutes)

  1. Node.js ≥ 18 installed (node -v).
  2. Redis ≥ 2.8.18 running. Easiest: docker run -p 6379:6379 -d redis.
  3. Check it: redis-cli pingPONG.
  4. A TypeScript project. Quick fresh one:
    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

The connection (read this bit)

Always pass 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.

Step 1 — Define the contract (typed payload)

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;
}

Step 2 — The producer (Queue)

// 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.

Step 3 — The consumer (Worker)

// 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:

Step 4 — Run it

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.

Tangible win. You just produced a job, durably stored it in Redis, and processed it in a separate process — all typed end-to-end. That is the whole game. Everything else is options on top of this loop.

Verify in Redis (build your intuition)

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.

What you just learned


🧠 Quiz — 10 questions, retrieval practice

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?


📚 Primary source to read next

Read the official Queues, Workers, and Connections pages (5–10 min each). They will deepen what you just touched.

📎 Reference

BullMQ Core Cheatsheet — the one-page lookup for everything in this lesson.

💬 Ask me next