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/queuesfor 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!\




