This guide shows you how to build a production-ready background job queue in Node.js using BullMQ 5 and Redis 7. You will go from zero to a working queue with retry logic, concurrency control, and a live dashboard — in roughly 90 minutes. This setup is used in production SaaS apps, e-commerce platforms, and automation pipelines across Australia, Singapore, Canada, and the US.

What You'll Build

  • A BullMQ job queue that processes tasks outside the HTTP request cycle
  • A worker with configurable concurrency and automatic retry on failure
  • A scheduled (cron) job that runs on a fixed interval
  • A Bull Board dashboard at /admin/queues for live job monitoring
  • A clean folder structure ready to extend in a real Node.js or Next.js project

Prerequisites

  • Node.js 20+ and pnpm 9 (or npm 10) installed locally
  • Redis 7 running locally via Docker or a managed service (Upstash, Redis Cloud)
  • Basic familiarity with async/await in TypeScript or JavaScript
  • A terminal and a code editor (VS Code recommended)

Step 1: Set Up Your Project and Install Dependencies

Why does this step matter?

BullMQ requires a clean module structure. Getting the folder layout right now prevents circular import issues later.

# Create the project
mkdir job-queue-demo && cd job-queue-demo
pnpm init -y

# Install runtime dependencies
pnpm add bullmq ioredis @bull-board/express @bull-board/api express

# Install dev dependencies
pnpm add -D typescript ts-node @types/node @types/express nodemon

# Generate tsconfig
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext --outDir dist

Expected result: A package.json and tsconfig.json at the project root.

Common pitfall: BullMQ 5 requires ioredis — it does not work with the legacy redis package. Always install ioredis explicitly.

Step 2: Start Redis Locally with Docker

What if I already have Redis running?

Skip this step. Just confirm Redis is reachable on port 6379 before continuing.

docker run -d --name redis-dev -p 6379:6379 redis:7-alpine

Expected result: Redis starts on localhost:6379. Verify with docker ps.

Pro tip: For production, use a managed Redis service. Upstash offers a free tier with TLS support and works well with BullMQ's ioredis connection options out of the box.

Step 3: Create a Shared Redis Connection

Reusing one Redis connection across your queue and workers avoids hitting Redis connection limits in production.

// src/redis.ts
import { Redis } from 'ioredis';

export const redisConnection = new Redis({
  host: process.env.REDIS_HOST ?? 'localhost',
  port: Number(process.env.REDIS_PORT ?? 6379),
  maxRetriesPerRequest: null, // required by BullMQ
});

Expected result: A single ioredis instance exported for reuse.

Common pitfall: Omitting maxRetriesPerRequest: null causes BullMQ to throw an error on startup. This is a hard requirement as of BullMQ 4+.

Step 4: Define Your Queue

A queue is a named channel where jobs are added. Workers pull jobs from it asynchronously.

// src/queues/emailQueue.ts
import { Queue } from 'bullmq';
import { redisConnection } from '../redis.js';

export const emailQueue = new Queue('email', {
  connection: redisConnection,
  defaultJobOptions: {
    attempts: 3,
    backoff: {
      type: 'exponential',
      delay: 1000, // starts at 1s, then 2s, then 4s
    },
    removeOnComplete: { count: 500 },
    removeOnFail: { count: 1000 },
  },
});

Expected result: An emailQueue instance with automatic retry and exponential backoff.

Why exponential backoff? If a third-party email API is temporarily down, hammering it immediately with retries makes things worse. Exponential backoff reduces pressure on the failing service while still retrying automatically.

Step 5: Build the Worker

What does a worker actually do?

A worker is a long-running Node.js process that picks up jobs from the queue and executes them. You can run multiple workers in parallel to scale throughput.

// src/workers/emailWorker.ts
import { Worker, Job } from 'bullmq';
import { redisConnection } from '../redis.js';

interface EmailJobData {
  to: string;
  subject: string;
  body: string;
}

const processEmailJob = async (job: Job) => {
  console.log(`[Job ${job.id}] Sending email to ${job.data.to}`);

  // Replace with your real email provider (SendGrid, Resend, SES, etc.)
  await new Promise((resolve) => setTimeout(resolve, 500)); // simulate send

  console.log(`[Job ${job.id}] Email sent successfully`);
};

export const emailWorker = new Worker(
  'email',
  processEmailJob,
  {
    connection: redisConnection,
    concurrency: 5, // process up to 5 jobs simultaneously
  }
);

emailWorker.on('completed', (job) => {
  console.log(`Job ${job.id} completed`);
});

emailWorker.on('failed', (job, err) => {
  console.error(`Job ${job?.id} failed:`, err.message);
});

Expected result: A worker that processes up to 5 email jobs concurrently with built-in error logging.

Pro tip: Set concurrency based on your job type. CPU-intensive jobs (image processing, PDF generation) should use lower concurrency — often 1–2. I/O-bound jobs (API calls, emails) can safely run at 10–20.

Step 6: Add a Cron-Scheduled Job

BullMQ supports repeatable jobs with cron syntax. This replaces the need for external cron services for many use cases.

// src/schedulers/dailyDigest.ts
import { emailQueue } from '../queues/emailQueue.js';

export const scheduleDailyDigest = async () => {
  await emailQueue.add(
    'daily-digest',
    {
      to: '[email protected]',
      subject: 'Daily Digest',
      body: 'Here is your daily summary.',
    },
    {
      repeat: {
        pattern: '0 9 * * *', // every day at 9:00 AM UTC
      },
    }
  );

  console.log('Daily digest job scheduled');
};

Common pitfall: Calling emailQueue.add with a repeat pattern on every app startup creates duplicate repeatable jobs. Call scheduleDailyDigest() only once — or check for existing repeatable jobs first using emailQueue.getRepeatableJobs().

Step 7: Mount the Bull Board Monitoring Dashboard

Why does monitoring matter?

Without visibility, failed jobs are silent. Bull Board gives you a live view of job states — active, waiting, completed, failed — without writing any custom UI.

// src/server.ts
import express from 'express';
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter.js';
import { ExpressAdapter } from '@bull-board/express';
import { emailQueue } from './queues/emailQueue.js';
import { emailWorker } from './workers/emailWorker.js';
import { scheduleDailyDigest } from './schedulers/dailyDigest.js';

const app = express();
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');

createBullBoard({
  queues: [new BullMQAdapter(emailQueue)],
  serverAdapter,
});

app.use('/admin/queues', serverAdapter.getRouter());

app.post('/api/send-email', express.json(), async (req, res) => {
  const job = await emailQueue.add('send-email', req.body);
  res.json({ jobId: job.id });
});

const start = async () => {
  await scheduleDailyDigest();
  app.listen(3000, () => {
    console.log('Server running at http://localhost:3000');
    console.log('Bull Board at http://localhost:3000/admin/queues');
  });
};

start();

Expected result: Navigate to http://localhost:3000/admin/queues and you will see a live dashboard showing all queues and job states.

Security note: In production, protect /admin/queues behind authentication middleware. Never expose it publicly. The OWASP Top 10 lists broken access control as the number one web application risk.

Step 8: Run and Test the Queue End to End

Add a start script to package.json:

{
  "scripts": {
    "dev": "nodemon --exec ts-node --esm src/server.ts"
  }
}
pnpm dev

Then add a test job using curl:

curl -X POST http://localhost:3000/api/send-email \\
  -H "Content-Type: application/json" \\
  -d '{"to":"[email protected]","subject":"Hello","body":"It works!\