Offload Slow Work to a Queue So Handlers Respond in Milliseconds
Background Jobs with BullMQ
Use BullMQ and Redis to enqueue background jobs from Koa handlers, process them in a separate worker, and understand why blocking the event loop inside a request handler is harmful.
What you'll learn
- Set up a BullMQ Queue and enqueue a job from a Koa route handler
- Process jobs in a dedicated Worker without tying up the HTTP server
- Explain why CPU-intensive or slow I/O work must not block the request cycle
Some operations — sending email, resizing images, generating PDFs, calling slow third-party APIs — take too long to complete inside an HTTP request. Blocking the event loop delays every other request being handled by that Node process. The solution is to enqueue a job and respond immediately while a separate worker process handles the heavy lifting.
Installation
npm install bullmq ioredis BullMQ uses Redis as its job store. A Queue object enqueues jobs; a Worker
object consumes them.
Creating the Queue
Define the queue in a shared module so both the HTTP server and the worker can import it.
// queues/emailQueue.js
import { Queue } from 'bullmq';
export const emailQueue = new Queue('email', {
connection: { host: process.env.REDIS_HOST, port: '6379' },
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 2_000 },
removeOnComplete: 100,
removeOnFail: 500,
},
}); Enqueueing from a Koa Handler
The handler adds the job to the queue and responds immediately — no waiting for the email to send.
import Router from '@koa/router';
import { emailQueue } from '../queues/emailQueue.js';
const router = new Router();
router.post('/users/:id/welcome-email', async (ctx) => {
const { id } = ctx.params;
await emailQueue.add('sendWelcome', { userId: id }, { priority: 1 });
ctx.status = 202; // 202 Accepted — work is queued, not yet done
ctx.body = { message: 'Email queued' };
});
export default router; The Worker Process
Run the worker in a separate Node process (or a separate file started by your process manager) so it cannot block HTTP request handling.
// workers/emailWorker.js
import { Worker } from 'bullmq';
import { sendEmail } from '../services/mailer.js';
import { getUserById } from '../services/userService.js';
const worker = new Worker(
'email',
async (job) => {
if (job.name === 'sendWelcome') {
const user = await getUserById(job.data.userId);
await sendEmail({
to: user.email,
subject: 'Welcome to the platform!',
text: `Hi ${user.name}, thanks for joining.`,
});
}
},
{ connection: { host: process.env.REDIS_HOST, port: '6379' } }
);
worker.on('completed', (job) => console.log(`Job ${job.id} done`));
worker.on('failed', (job, err) => console.error(`Job ${job.id} failed`, err)); Start it with:
node workers/emailWorker.js Why Not Block the Request?
| Approach | Latency | Reliability | Scalability |
|---|---|---|---|
| Await work inside handler | High (seconds) | Fails on timeout/crash | Poor — blocks other requests |
| Fire-and-forget (no queue) | Low | No retry on failure | Better, but jobs lost on crash |
| BullMQ queue + worker | Low (ms) | Automatic retry with backoff | Excellent — scale workers independently |
Node’s event loop is single-threaded. A 2-second synchronous operation in one handler delays every other pending request for those 2 seconds. Queuing moves that work off the critical path entirely.
Up Next
Learn how to test Koa applications — middleware, routes, and error handling — with confidence using supertest and a test database.
Testing Koa Applications →