You launch production — and the server crashes under the load of sending emails? Or cron jobs execute out of order, reports vanish into thin air? Synchronous processing slows down the API, users get errors. Typical scenario: 10,000 emails per minute — the database locks up, latency climbs to 30 seconds. BullMQ solves these problems: async queue on Redis with priorities, retries, and monitoring. In one project with 50,000 users after implementing BullMQ, server load dropped by 70%, and API response time went from 2 seconds to 200 ms. Our experience — over 10 years and 50+ queue projects. A properly configured queue reduces downtime and eliminates data loss. Monthly savings amount to a significant sum by lowering server load and cutting development time.
We've set up BullMQ for dozens of projects — from startups to enterprise. This article covers a battle-tested configuration from scratch to production, turnkey.
Installation
Install the packages: npm install bullmq ioredis. BullMQ requires Redis version 6+. Make sure the server is reachable.
Connection Configuration
// lib/redis.ts
import { Redis } from 'ioredis';
export const redisConnection = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD,
maxRetriesPerRequest: null,
enableReadyCheck: false,
});
maxRetriesPerRequest: null — critical for proper reconnection. enableReadyCheck: false speeds up startup.
Defining Queues
// queues/index.ts
import { Queue } from 'bullmq';
import { redisConnection } from '../lib/redis';
const defaultJobOptions = {
attempts: 3,
backoff: {
type: 'exponential' as const,
delay: 5000,
},
removeOnComplete: { count: 1000, age: 86400 },
removeOnFail: { count: 5000, age: 604800 },
};
export const emailQueue = new Queue('emails', {
connection: redisConnection,
defaultJobOptions,
});
// For other task types, create similar queues with the same options.
For different task types, use separate queues — it improves monitoring and performance.
Workers
// workers/emailWorker.ts
import { Worker, Job } from 'bullmq';
import { redisConnection } from '../lib/redis';
import { sendEmail } from '../services/email';
interface EmailJobData {
to: string;
subject: string;
template: string;
variables: Record<string, unknown>;
}
const worker = new Worker<EmailJobData>(
'emails',
async (job: Job<EmailJobData>) => {
const { to, subject, template, variables } = job.data;
await job.updateProgress(10);
await sendEmail({ to, subject, template, variables });
await job.updateProgress(100);
return { sent: true, to, timestamp: new Date().toISOString() };
},
{
connection: redisConnection,
concurrency: 10,
limiter: {
max: 100,
duration: 60_000,
},
}
);
worker.on('completed', (job, result) => {
console.log(`Email sent to ${result.to}`);
});
worker.on('failed', (job, err) => {
console.error(`Email failed: job ${job?.id}:`, err.message);
});
worker.on('error', (err) => {
console.error('Worker error:', err);
});
export default worker;
Rate limiting is a must-have for production. Without it, you risk getting blocked by the recipient's external API.
Examples of Adding Tasks
Below are several scenarios: simple send, delayed, priority, bulk, cron jobs, and Flow.
// Simple task
await emailQueue.add('welcome-email', {
to: user.email,
subject: 'Welcome!',
template: 'welcome',
variables: { name: user.name },
});
// Delayed 5 minutes
await emailQueue.add('follow-up-email', {
to: user.email,
subject: 'How are you?',
template: 'follow-up',
variables: { name: user.name },
}, {
delay: 5 * 60 * 1000,
});
// With priority (1 – highest)
await notificationQueue.add('push-notification', {
userId: user.id,
message: 'Urgent notification',
}, {
priority: 1,
});
// Bulk sending
const jobs = users.map(user => ({
name: 'newsletter',
data: { to: user.email, template: 'newsletter' },
opts: { delay: Math.random() * 60_000 },
}));
await emailQueue.addBulk(jobs);
// Cron: daily report at 9:00 UTC
await reportQueue.add(
'daily-report',
{ type: 'daily', recipients: ['[email protected]'] },
{
repeat: { pattern: '0 9 * * *' },
jobId: 'daily-report-unique',
}
);
// Flow: resize → upload → notification
import { FlowProducer } from 'bullmq';
const flow = new FlowProducer({ connection: redisConnection });
await flow.add({
name: 'notify-user',
queueName: 'notifications',
data: { userId },
children: [{
name: 'upload-to-s3',
queueName: 'uploads',
data: { tempPath },
children: [{
name: 'resize-image',
queueName: 'images',
data: { originalPath, sizes: [200, 400, 800] },
}],
}],
});
More about cron jobs
For recurring tasks, use the `repeat` option with `pattern` (cron) or `every` (interval). A unique `jobId` prevents duplicates on repeated runs.Bull Board (Monitoring)
Bull Board is a web interface for managing queues. Integration with Express:
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { emailQueue, notificationQueue, reportQueue } from './queues';
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [
new BullMQAdapter(emailQueue),
new BullMQAdapter(notificationQueue),
new BullMQAdapter(reportQueue),
],
serverAdapter,
});
app.use('/admin/queues', authenticate, serverAdapter.getRouter());
Bull Board gives full control: view tasks, re-run, clean up. Monitoring doesn't overload Redis — requests are asynchronous. 80% of tasks succeed on the first attempt, and the delay between retries increases exponentially: 5, 10, 20 seconds.
Why BullMQ over EventEmitter or RabbitMQ?
BullMQ is 3x faster for typical web tasks than RabbitMQ and doesn't require a license purchase. Unlike EventEmitter, data is persisted in Redis — tasks aren't lost on server crash. BullMQ supports exponential backoff and priorities, which EventEmitter lacks. Comparison:
| Feature | BullMQ | RabbitMQ | EventEmitter |
|---|---|---|---|
| Persistence | Yes (via Redis) | Yes | No |
| Priorities | Yes | No | No |
| Exponential backoff | Yes | Requires configuration | No |
| Monitoring | Bull Board | Management UI | No |
| License | Open Source | Open Source (enterprise available) | Free |
How to Monitor Queues with Bull Board?
Bull Board is a web interface that shows all queues, workers, task statuses, and errors. Integration with Express is described above. Add the middleware and you get a dashboard at /admin/queues. Bull Board runs stably on projects with 10+ queues and 1000 tasks per minute. If needed, we add authentication and role-based access.
How to Avoid Data Loss During Failures?
Use Redis persistence — configure save in redis.conf. In BullMQ, tasks are retained until processed or TTL expires. The removeOnComplete parameter with count: 1000 ensures only the last 1000 successful jobs stay in memory — saving RAM. If Redis crashes, use AOF or replication. In our projects, we configure Redis with appendonly yes and sync every 5 seconds.
Common Mistakes in Queue Setup
- Forgetting to set
maxRetriesPerRequest: null— connection drops after the first error. - Skipping
enableReadyCheck: false— queue start delays by seconds. - Not setting rate limits — external API blocks requests (HTTP 429).
- Using a single queue for all task types — complicates monitoring and debugging.
What's Included in Turnkey Queue Setup
| Stage | Description |
|---|---|
| Analysis | Load assessment, strategy selection (delay, priority, retries) |
| Design | Queue schema, Redis configuration, rate limiting setup |
| Implementation | Writing queues, workers, Flow chains |
| Monitoring | Bull Board installation, alerts to Telegram/Slack |
| Documentation | README with architecture, deployment instructions |
| Support | 2-week setup warranty after delivery |
Implementation Timeline
BullMQ for a typical Node.js project (emails, notifications, cron): 2–3 days. With Bull Board, monitoring, and Flow: 3–4 days. Get a free assessment for your project. Over 10 years of experience and 50+ projects guarantee reliability. Order professional BullMQ setup with a result guarantee. Get an engineer consultation right now.







