βοΈ Bull Queue Implementation
Time to make the mental model concrete. In this lesson you'll wire up BullMQ on top of Redis, create a queue, push jobs onto it from your API, and run a separate worker process that drains them β complete with retries, concurrency, and a live event stream so you can watch every job move through its lifecycle.
Week 10 · Day 5 (Friday: Background Jobs) · Lecture 2
π― Learning Objectives
By the end of this lesson, you will be able to:
- Install and connect BullMQ to a Redis instance with a shared connection config
- Create a
Queueand add jobs with options likeattempts,backoff,delay, andpriority - Run a
Workerin a separate process, controllingconcurrencyand reporting progress - Observe job outcomes with
QueueEventsand per-workercompleted/failedlisteners - Handle failures with retries, inspect failed jobs, and write idempotent job handlers
Estimated Time: 75 minutes
Project: Build an email queue β an API route that enqueues, and a worker that "sends" with retry and backoff.
In This Lesson
Setup: Redis & BullMQ
BullMQ stores its queues in Redis, so you need a Redis server running. In development the quickest path is Docker; in production you'll point at a managed Redis (or a hosted service). Then install the library.
// Start Redis locally with Docker (one line, no install):
// docker run -d --name redis -p 6379:6379 redis:7
// Install BullMQ (v5 is the current major line):
// npm install bullmq
BullMQ (the modern successor to the original "Bull") splits responsibilities into small, focused classes. Three are the heart of everything:
| Class | Role | Lives in |
|---|---|---|
Queue | The producer side β add jobs | Your web/API process |
Worker | The consumer side β process jobs | A separate worker process |
QueueEvents | A global stream of lifecycle events | Wherever you want to observe |
Because a connection config is needed everywhere, define it once and import it. This keeps every part of the system pointed at the same Redis.
// queues/connection.js
// One place to configure how everything reaches Redis.
export const connection = {
host: process.env.REDIS_HOST || '127.0.0.1',
port: Number(process.env.REDIS_PORT) || 6379,
// password: process.env.REDIS_PASSWORD, // for managed Redis
};
π Why a whole separate database for jobs?
Redis is single-threaded and atomic, which makes it perfect for a queue: two workers can't grab the same job, and Redis' data structures (lists, sorted sets) map cleanly onto "waiting," "delayed," and "active." BullMQ leans on those primitives so you get correct locking and ordering for free.
Creating a Queue & Adding Jobs
A Queue is your producer handle. You give it a name (a namespace in Redis) and the connection. Then you call add(jobName, data, options) to enqueue work. The data is any JSON-serializable object β keep it small.
// queues/emailQueue.js
import { Queue } from 'bullmq';
import { connection } from './connection.js';
// The queue name 'email' is shared by the producer and the worker.
export const emailQueue = new Queue('email', { connection });
Now enqueue from an Express route. Notice how little the handler does β create the record, add the job, respond. The email hasn't been sent yet, and that's the point.
// server.js (the producer / API process)
import express from 'express';
import { emailQueue } from './queues/emailQueue.js';
const app = express();
app.use(express.json());
app.post('/signup', async (req, res) => {
const user = await User.create(req.body);
// Add a 'welcome' job. First arg = job name, second = payload.
await emailQueue.add('welcome', {
userId: user.id,
to: user.email,
template: 'welcome'
});
// Respond immediately β the worker will send the email later.
res.status(201).json({ id: user.id });
});
app.listen(3000, () => console.log('API on http://localhost:3000'));
What just happened in Redis
Job "welcome" (id: 1) β state: waiting
payload: { userId: 42, to: "ada@example.com", template: "welcome" }
The job now sits in the waiting list. Nothing runs until a worker connects and pulls it β which is exactly what we build next.
The Worker Process
A Worker connects to the same queue name and supplies a processor function β an async function that receives a job and does the work. When it returns, the job is completed; if it throws, the job is failed (and retried if attempts remain).
Critically, this runs as its own process, separate from the web server. That's what gives you independent scaling and stops a heavy job from blocking incoming HTTP requests.
// worker.js (run with: node worker.js β a SEPARATE process)
import { Worker } from 'bullmq';
import { connection } from './queues/connection.js';
import { sendMail } from './services/mailer.js';
const worker = new Worker(
'email', // must match the queue name
async (job) => { // the processor function
const { to, template } = job.data;
console.log(`Sending "${template}" to ${to}β¦`);
// The actual slow work. If it throws, BullMQ handles the retry.
const info = await sendMail({ to, template });
// Whatever you return is stored as the job's result.
return { messageId: info.messageId };
},
{ connection, concurrency: 5 } // run up to 5 jobs at once
);
console.log('Email worker started, waiting for jobsβ¦');
Concurrency
The concurrency option is how many jobs a single worker runs in parallel. Because email is I/O-bound (mostly waiting on the network), a concurrency of 5β50 is fine β one worker can juggle many in-flight sends. CPU-bound work (image processing) should use low concurrency and instead scale by running more worker processes.
π‘ Two dials for throughput
Concurrency scales within a process (great for I/O-bound jobs). More worker processes scale across cores and machines (needed for CPU-bound jobs). Real systems tune both. Start a second worker and BullMQ automatically shares the queue between them β no code change.
Reporting progress
Long jobs can report a percentage so a UI can show a progress bar. Call job.updateProgress() inside the processor.
const worker = new Worker('image', async (job) => {
const { sizes } = job.data;
let done = 0;
for (const size of sizes) {
await resizeTo(size);
done++;
// Report progress as a percentage (0β100).
await job.updateProgress(Math.round((done / sizes.length) * 100));
}
return { count: sizes.length };
}, { connection });
Job Options: Retries, Backoff & Delay
The third argument to add() is where the reliability magic lives. These options ride along with the job in Redis and control how BullMQ retries and schedules it.
await emailQueue.add('welcome', payload, {
attempts: 5, // try up to 5 times before failing for good
backoff: {
type: 'exponential', // wait 2s, 4s, 8s, 16s between attempts
delay: 2000 // base delay in ms
},
delay: 10_000, // don't run until 10s from now (delayed state)
priority: 1, // lower number = higher priority
removeOnComplete: 1000, // keep only the last 1000 completed jobs
removeOnFail: 5000 // keep the last 5000 failed jobs for inspection
});
| Option | What it does |
|---|---|
attempts | Total tries before the job is marked failed permanently |
backoff | How long to wait between retries β fixed or exponential |
delay | Milliseconds to hold the job in delayed before it becomes waiting |
priority | Ordering hint; lower runs sooner. Great for "critical" vs "bulk" jobs |
removeOnComplete / removeOnFail | Auto-cleanup so Redis doesn't fill with finished jobs |
β οΈ Always set removeOnComplete
By default BullMQ keeps completed jobs forever. On a busy queue that quietly eats all of Redis' memory until things fall over. Set removeOnComplete (a count or an age) on every queue. Keep more removeOnFail history β failed jobs are the ones you actually need to look at.
Setting shared defaults
Rather than repeating options at every add(), set defaultJobOptions on the queue itself.
export const emailQueue = new Queue('email', {
connection,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: 500,
removeOnFail: 2000
}
});
Events & Progress
You have two ways to observe what's happening, and they serve different purposes.
Worker-local listeners
The Worker emits events for the jobs it processed. Perfect for logging inside the worker process.
worker.on('completed', (job, result) => {
console.log(`Job ${job.id} completed β`, result);
});
worker.on('failed', (job, err) => {
console.error(`Job ${job?.id} failed:`, err.message);
});
worker.on('progress', (job, progress) => {
console.log(`Job ${job.id} is ${progress}% done`);
});
QueueEvents β the global stream
QueueEvents subscribes to all lifecycle events for a queue, from any process. Use it in your API or a dashboard to react to jobs a different process ran β for example, to push a WebSocket update when an image finishes.
// Can live in the API process, watching work done by the worker process.
import { QueueEvents } from 'bullmq';
import { connection } from './queues/connection.js';
const queueEvents = new QueueEvents('email', { connection });
queueEvents.on('completed', ({ jobId, returnvalue }) => {
console.log(`[events] job ${jobId} done β`, returnvalue);
});
queueEvents.on('failed', ({ jobId, failedReason }) => {
console.log(`[events] job ${jobId} failed: ${failedReason}`);
});
queueEvents.on('progress', ({ jobId, data }) => {
console.log(`[events] job ${jobId} progress: ${data}%`);
});
β Which one do I use?
Use worker listeners for logging inside the worker. Use QueueEvents when a different process needs to know a job's outcome β the classic case being your API notifying a browser over WebSockets that a background job just finished.
Console output, worker + events together
Sending "welcome" to ada@example.comβ¦
Job 1 completed β { messageId: '<abc@mail>' }
[events] job 1 done β { messageId: '<abc@mail>' }
Handling Failures
Failures are normal, not exceptional β mail servers time out, APIs return 500s. BullMQ's job is to make failures recoverable. When your processor throws, the job is retried per its attempts/backoff until it either succeeds or exhausts its attempts and lands in the failed set.
const worker = new Worker('email', async (job) => {
try {
return await sendMail(job.data);
} catch (err) {
// Distinguish transient from permanent failures.
if (err.code === 'EInvalidAddress') {
// Permanent β don't waste retries. Throw a marker you can detect,
// or discard by returning; here we rethrow so it's recorded as failed.
throw new Error(`Undeliverable address: ${job.data.to}`);
}
// Transient (timeout, 5xx) β rethrow so BullMQ retries with backoff.
throw err;
}
}, { connection });
Inspecting failed jobs
Because you set removeOnFail to keep history, you can list and examine what went wrong β essential for debugging and for building a retry-by-hand admin action.
// A small script or admin endpoint to review the dead-letter set.
const failed = await emailQueue.getJobs(['failed'], 0, 20);
for (const job of failed) {
console.log({
id: job.id,
name: job.name,
reason: job.failedReason,
attemptsMade: job.attemptsMade,
data: job.data
});
// await job.retry(); // re-queue a job you believe will now succeed
}
β οΈ Idempotency still applies
A retried job runs your processor again from the top. If the first attempt already charged a card or wrote a row before failing on the next step, the retry must not repeat that side effect. Guard every side effect with an idempotency key or an "already done?" check β the same discipline from the previous lesson.
Graceful shutdown
When you deploy, don't kill a worker mid-job. Close it cleanly so the in-flight job finishes (or is released back to waiting).
process.on('SIGTERM', async () => {
console.log('Shutting down workerβ¦');
await worker.close(); // stop taking new jobs, finish current ones
process.exit(0);
});
Practice & Quiz
ποΈ Exercise 1: A resilient notification job
Goal: Create a notifications queue whose jobs retry up to 4 times with exponential backoff starting at 3 seconds, keep the last 100 completed and 1000 failed jobs, and add a job that is delayed by 30 seconds. Write only the queue creation and the add() call.
π‘ Hint
Put the retry/backoff/cleanup in defaultJobOptions on the Queue, and put the one-off delay in the add() options.
β Solution
import { Queue } from 'bullmq';
import { connection } from './queues/connection.js';
export const notificationsQueue = new Queue('notifications', {
connection,
defaultJobOptions: {
attempts: 4,
backoff: { type: 'exponential', delay: 3000 },
removeOnComplete: 100,
removeOnFail: 1000
}
});
await notificationsQueue.add(
'push',
{ userId: 42, message: 'Your report is ready' },
{ delay: 30_000 } // held in "delayed" for 30 seconds
);
ποΈ Exercise 2: Complete the worker
Goal: Write a worker for the notifications queue with concurrency 10 that logs completions and failures. It should call an async deliver(job.data) and return its result.
β Solution
import { Worker } from 'bullmq';
import { connection } from './queues/connection.js';
import { deliver } from './services/notifier.js';
const worker = new Worker(
'notifications',
async (job) => {
return await deliver(job.data); // throw on failure β BullMQ retries
},
{ connection, concurrency: 10 }
);
worker.on('completed', (job) => console.log(`β ${job.id} delivered`));
worker.on('failed', (job, err) =>
console.error(`β ${job?.id} failed (attempt ${job?.attemptsMade}):`, err.message)
);
π― Quick Quiz
Question 1: Where should a Worker run?
Question 2: A processor function throws an error and the job still has attempts left. What state does the job go to next?
Question 3: Which tool lets your API process react to a job that the worker process completed?
Best Practices & Pitfalls
β Do
- Share one connection config module across queue, worker, and events
- Set
removeOnCompleteandremoveOnFailon every queue - Match concurrency to the work: high for I/O-bound, low + more processes for CPU-bound
- Throw on transient errors so BullMQ retries; keep processors idempotent
- Close workers with
worker.close()onSIGTERMfor zero-downtime deploys
β Don't
- Don't create a
new Queueper request β instantiate once and reuse - Don't run the worker in the same process as your web server for real workloads
- Don't stuff large blobs into
job.dataβ pass an id and fetch inside the job - Don't ignore the
failedset β monitor it and alert on growth
β οΈ Bull vs BullMQ
You'll see older tutorials using the original bull package with queue.process() and queue.add(data, opts). This lesson uses the newer bullmq, where processing lives in a dedicated Worker class and add() takes a job name first. Same concepts, cleaner API β prefer BullMQ for new code.
Summary
π Key Takeaways
- BullMQ splits the job into
Queue(producer),Worker(consumer), andQueueEvents(observer), all backed by Redis - Add jobs with
queue.add(name, data, options); process them with aWorker's async function in a separate process attempts+backoffgive you automatic retries;delayandpriorityschedule and order work- Always set
removeOnComplete/removeOnFail, tuneconcurrency, and keep processors idempotent - Observe with worker listeners locally and
QueueEventsglobally; close workers cleanly on shutdown
π Additional Resources
- BullMQ β Queues guide
- BullMQ β Workers & concurrency
- BullMQ β Jobs, options & retries
- BullMQ β QueueEvents API reference
π What's Next?
You can now run work on demand. But a lot of background work runs on a clock β a nightly cleanup, a Monday-morning report. In the next lesson, Scheduled Tasks, you'll learn cron syntax and schedule recurring jobs with both node-cron and BullMQ's built-in repeatable jobs.
π You built a real queue!
Producer, worker, retries, events β this is production-grade background processing.