Getting Started
Run BullMQ with Redis - enqueue a job, process it, schedule a recurring task
Getting Started
This page boots BullMQ (Node.js's modern job queue) on Redis, defines a job, processes it, and schedules a recurring task.
Same shapes work for Sidekiq (Ruby), Celery (Python), and most other queue libraries — different syntax, same concepts.
Bring Up Redis
# docker-compose.yml
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes:
- redis-data:/data
command: redis-server --appendonly yes
volumes:
redis-data:docker compose up -d
docker compose exec redis redis-cli ping
# PONGDefine and Enqueue a Job
npm install bullmq// queue.js — the producer side
const { Queue } = require('bullmq');
const emailQueue = new Queue('emails', {
connection: { host: 'localhost', port: 6379 },
});
// Enqueue
async function sendWelcomeEmail(userId, email) {
await emailQueue.add('welcome', { userId, email }, {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: 100, // keep last 100 successful
removeOnFail: 1000, // keep last 1000 failed
});
}
// From your web handler:
app.post('/signup', async (req, res) => {
const user = await createUser(req.body);
await sendWelcomeEmail(user.id, user.email); // returns instantly
res.json({ ok: true });
});The web request returns in milliseconds. The actual email send happens in a worker process.
Process the Job
// worker.js — runs as a separate process
const { Worker } = require('bullmq');
const worker = new Worker(
'emails',
async (job) => {
console.log(`Processing job ${job.id}: ${job.name}`);
const { userId, email } = job.data;
// do the actual work
await sendEmail(email, await renderWelcomeTemplate(userId));
return { sent: true, at: new Date().toISOString() };
},
{
connection: { host: 'localhost', port: 6379 },
concurrency: 5, // process 5 jobs in parallel
limiter: {
max: 100,
duration: 60_000, // 100 jobs/minute max
},
}
);
worker.on('completed', (job, result) => console.log(`Done ${job.id}:`, result));
worker.on('failed', (job, err) => console.error(`Failed ${job.id}:`, err.message));Run the worker in its own process:
node worker.jsNow web servers handle web traffic; worker fleet handles jobs. Scale them independently — you can have 2 web servers and 10 workers if you're send-heavy.
Retries and Backoff
BullMQ retries automatically based on the options at enqueue time:
await queue.add('process-payment', { orderId }, {
attempts: 5,
backoff: { type: 'exponential', delay: 2000 }, // 2s, 4s, 8s, 16s, 32s
});If the worker function throws, BullMQ retries with the configured delay. After attempts exhaust, the job goes to the failed state.
Dead-Letter Pattern
Failed jobs are visible in BullMQ's UI / getFailed() API. For automatic dead-lettering:
const worker = new Worker('emails', processJob, {
// ...
});
worker.on('failed', async (job, err) => {
if (job.attemptsMade >= job.opts.attempts) {
// exhausted retries — move to a "dead letter" queue
await deadLetterQueue.add('dead', {
originalQueue: 'emails',
originalJob: job.data,
error: err.message,
failedAt: new Date().toISOString(),
});
}
});Now you have a separate queue of failed jobs you can inspect, fix, and replay.
Scheduled Jobs
Run a job at a specific time:
// 1 hour from now
await queue.add('reminder', { userId }, { delay: 60 * 60 * 1000 });
// Specific timestamp
await queue.add('publish-post', { postId }, {
delay: new Date('2025-06-01T09:00:00Z').getTime() - Date.now(),
});Recurring (Cron) Jobs
// Run every day at 06:00 UTC
await queue.add('daily-summary', {}, {
repeat: { pattern: '0 6 * * *' }, // cron pattern
});
// Or every 30 minutes
await queue.add('check-pending', {}, {
repeat: { every: 30 * 60 * 1000 },
});This replaces cron + a script that POSTs to your app. The job runs in your worker fleet with the same retry / observability story as any other job.
Priority Queues
BullMQ supports priorities:
await queue.add('email', emailData, { priority: 1 }); // higher priority
await queue.add('email', emailData, { priority: 10 }); // lower priority (default)Lower number = higher priority. Useful for "premium users get jumped to the front of the line."
Rate Limiting
Limit how fast a queue's workers process — useful when calling a rate-limited external API:
const worker = new Worker('email', processEmail, {
limiter: { max: 10, duration: 1000 }, // 10 jobs/second across this worker
});For cross-worker rate limiting (3rd-party API quotas), wrap the API call in a separate rate-limited service or use Redis-based rate limiters.
The Dashboard
BullMQ ships Bull Board for inspecting queues:
npm install @bull-board/api @bull-board/expressconst { createBullBoard } = require('@bull-board/api');
const { BullMQAdapter } = require('@bull-board/api/bullMQAdapter');
const { ExpressAdapter } = require('@bull-board/express');
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [new BullMQAdapter(emailQueue), new BullMQAdapter(reportsQueue)],
serverAdapter,
});
app.use('/admin/queues', adminAuthMiddleware, serverAdapter.getRouter());Now /admin/queues shows you live queues, in-progress jobs, completed/failed/scheduled jobs. Put it behind auth.
Sidekiq has the analogous Sidekiq::Web; Celery has Flower.
Equivalent in Other Languages
Ruby / Sidekiq
# Enqueue
WelcomeEmailJob.perform_async(user_id, email)
# Worker
class WelcomeEmailJob
include Sidekiq::Job
sidekiq_options retry: 3
def perform(user_id, email)
UserMailer.welcome(user_id, email).deliver_now
end
endPython / Celery
# Enqueue
welcome_email.delay(user_id, email)
# Worker
@app.task(bind=True, autoretry_for=(Exception,), retry_kwargs={'max_retries': 3})
def welcome_email(self, user_id, email):
send_email(email, render_welcome(user_id))Tear Down
docker compose down -vWhat's Next
You can enqueue and process jobs reliably. When jobs grow into multi-step workflows, you graduate:
- Jobs vs Workflows — when to reach for Temporal, what durable execution gives you
- Best Practices — idempotency, retries, dead-letter, observability, scaling