Background Jobs with BullMQ

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.

4 min read Level 3/5 #koa#data#jobs
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?

ApproachLatencyReliabilityScalability
Await work inside handlerHigh (seconds)Fails on timeout/crashPoor — blocks other requests
Fire-and-forget (no queue)LowNo retry on failureBetter, but jobs lost on crash
BullMQ queue + workerLow (ms)Automatic retry with backoffExcellent — 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 →