If your API is doing work that takes more than 100ms -- sending emails, processing images, syncing data -- you should be offloading it to a queue. Here's how.
When You Need a Queue
- Email sending (SMTP calls are slow and can fail)
- Image/video processing
- PDF generation
- External API calls that can fail and need retry
- Webhook delivery
- Large batch operations
- Anything that doesn't need to be done synchronously
The pattern: API receives request, puts a job in the queue, returns 202 Accepted. Worker picks up job, processes it, reports result.
BullMQ (Redis-based, Recommended for Node.js)
BullMQ is the best choice for most Node.js applications:
npm install bullmq ioredis
// queue.ts
import { Queue, Worker, QueueEvents } from 'bullmq'
import IORedis from 'ioredis'
const connection = new IORedis(process.env.REDIS_URL!)
// Create a queue
export const emailQueue = new Queue('emails', { connection })
// Add a job
export async function queueWelcomeEmail(userId: string) {
await emailQueue.add(
'welcome-email',
{ userId },
{
attempts: 3, // Retry up to 3 times
backoff: {
type: 'exponential',
delay: 2000 // 2s, 4s, 8s between retries
},
removeOnComplete: 100, // Keep last 100 completed jobs
removeOnFail: 200, // Keep last 200 failed jobs
}
)
}
// worker.ts
const emailWorker = new Worker(
'emails',
async (job) => {
if (job.name === 'welcome-email') {
const { userId } = job.data
const user = await db.users.findById(userId)
await sendEmail({
to: user.email,
template: 'welcome',
data: { name: user.name }
})
return { sent: true }
}
},
{
connection,
concurrency: 5, // Process 5 jobs simultaneously
}
)
emailWorker.on('completed', (job, result) => {
console.log(`Job ${job.id} completed:`, result)
})
emailWorker.on('failed', (job, error) => {
console.error(`Job ${job?.id} failed:`, error.message)
// Alert if all retries exhausted
if (job?.attemptsMade === job?.opts.attempts) {
alertOncall(`Email job ${job.id} failed permanently`)
}
})
Job Prioritization
// Higher priority jobs are processed first (lower number = higher priority)
await emailQueue.add('password-reset', data, { priority: 1 }) // Urgent
await emailQueue.add('marketing-email', data, { priority: 10 }) // Can wait
Scheduled Jobs
// Recurring job
await emailQueue.add(
'weekly-digest',
{},
{
repeat: { cron: '0 9 * * 1' } // Every Monday at 9am
}
)
// Delayed job
await emailQueue.add(
'follow-up-email',
{ userId },
{
delay: 3 * 24 * 60 * 60 * 1000 // 3 days from now
}
)
RabbitMQ: When You Need Cross-Language Pub/Sub
If you have services in multiple languages or need complex routing:
import amqp from 'amqplib'
const connection = await amqp.connect(process.env.RABBITMQ_URL!)
const channel = await connection.createChannel()
// Declare exchange and queues
await channel.assertExchange('events', 'topic', { durable: true })
await channel.assertQueue('order-processing', { durable: true })
await channel.bindQueue('order-processing', 'events', 'order.*')
// Publish
channel.publish(
'events',
'order.created',
Buffer.from(JSON.stringify({ orderId: '123', amount: 5000 })),
{ persistent: true } // Survive broker restart
)
// Consume
channel.consume('order-processing', (msg) => {
if (!msg) return
const order = JSON.parse(msg.content.toString())
processOrder(order)
.then(() => channel.ack(msg))
.catch(() => channel.nack(msg, false, true)) // Requeue on failure
}, { noAck: false })
BullMQ vs RabbitMQ
| BullMQ | RabbitMQ | |
|---|---|---|
| Language | Node.js native | Any language |
| Persistence | Redis | RabbitMQ broker |
| Job UI | Bull Board | RabbitMQ management |
| Delayed jobs | Built-in | Plugin needed |
| Priority | Built-in | Per-queue |
| Complexity | Low | Medium |
| Best for | Node.js background jobs | Multi-service event bus |
Bull Board: Job Monitoring UI
import { createBullBoard } from '@bull-board/api'
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'
import { ExpressAdapter } from '@bull-board/express'
const serverAdapter = new ExpressAdapter()
createBullBoard({
queues: [new BullMQAdapter(emailQueue)],
serverAdapter
})
app.use('/admin/queues', serverAdapter.getRouter())
At localhost:3000/admin/queues, you'll see all jobs, their status, retry counts, and the ability to retry failed jobs manually.
Start with BullMQ for any Node.js app that needs background processing. Only add RabbitMQ when you need cross-service event routing that BullMQ can't handle.