Most of the time, unknowingly, a developer-kun uses synchronous APIs where the server expects a response immediately and halts all other work.
This works fine when resources are plentiful and latency is low.
But flip the situation — backend is busy, latency is high. A sync API will wait indefinitely unless programmed otherwise. Bad UX. Angry users. Sad developer-kun.
The Problem — A Real Example
A user signs up on your platform. Your server needs to:
- Send a welcome email — 2 seconds
- Receive and process their profile picture — 3 seconds
- Send a Slack notification — 1 second
- Log the event to analytics — 1 second
Total: 7 seconds of the user staring at a loading spinner.
On one server, doing everything synchronously, that's what you get. And that's assuming nothing goes wrong — if the email service is slow that day, add another 5 seconds on top.
The user thinks your app is broken. They leave. You cry.
The Fix — Async Architecture with Queues
Instead of doing everything in the request-response cycle, you:
- Save the user to the database
- Queue up the heavy work
- Return a response immediately
- Workers pick up the queued jobs in the background
User signs up
↓
Server saves to DB (fast)
↓
Queues: welcome email, profile processing, slack notification, analytics
↓
Returns response in ~50ms ← user is happy
Meanwhile, in the background:
Worker picks up jobs one by one
Emails sent -> Slack notified -> Analytics logged
The user gets their response in 50ms. The work still happens. Nobody waits.
Enter BullMQ
BullMQ is a Node.js job queue library backed by Redis. Think of it as a post office:
- Producer — your API drops a letter (job) in the mailbox (queue)
- Queue — holds the letters until a postman is free
- Worker — the postman who picks up letters and delivers them
npm install bullmq ioredis
The Queue — Your Mailbox
import { Queue } from 'bullmq';
import Redis from 'ioredis';
// Connect to Redis — BullMQ uses Redis to store jobs
const connection = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
maxRetriesPerRequest: null, // required by BullMQ
});
// Define the queue
export const emailQueue = new Queue('emails', { connection });
// Helper to add a welcome email job
export async function queueWelcomeEmail(user) {
await emailQueue.add(
'welcome-email', // job name
{ // job data — available to the worker
userId: user.id,
email: user.email,
name: user.name,
},
{
attempts: 3, // retry up to 3 times if it fails
backoff: {
type: 'exponential',
delay: 2000, // wait 2s, then 4s, then 8s between retries
},
removeOnComplete: 100, // keep last 100 completed jobs in Redis
removeOnFail: 50, // keep last 50 failed jobs for inspection
}
);
console.log(`Queued welcome email for ${user.email}`);
}
// Helper to add an account deletion email job
export async function queueAccountDeleteEmail(user) {
await emailQueue.add(
'account-delete-email',
{
userId: user.id,
email: user.email,
name: user.name,
},
{
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 100,
removeOnFail: 50,
}
);
console.log(`Queued account deletion email for ${user.email}`);
}
What's happening here?
new Queue('emails', { connection }) — creates a queue called "emails" backed by Redis. If the queue doesn't exist, BullMQ creates it automatically.
emailQueue.add(name, data, options) — drops a job into the queue. Returns immediately — this is the non-blocking part. Your API doesn't wait for the email to send.
attempts: 3 — if the job fails (email service is down, network error etc), BullMQ retries automatically up to 3 times.
backoff: exponential — waits 2s before retry 1, 4s before retry 2, 8s before retry 3. Gives the failing service time to recover instead of hammering it.
The Worker — Your Postman
import { Worker } from 'bullmq';
import Redis from 'ioredis';
const connection = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
maxRetriesPerRequest: null,
});
const worker = new Worker(
'emails', // which queue to watch
async (job) => { // function to run for each job
console.log(`Processing job: ${job.name}`, job.data);
if (job.name === 'welcome-email') {
const { email, name } = job.data;
await sendWelcomeEmail(email, name);
} else if (job.name === 'account-delete-email') {
const { email, name } = job.data;
await sendAccountDeleteEmail(email, name);
}
},
{
connection,
concurrency: 5, // process up to 5 jobs simultaneously
}
);
// Simulate sending a welcome email
async function sendWelcomeEmail(email, name) {
await new Promise(resolve => setTimeout(resolve, 1000)); // simulate delay
console.log(` Welcome email sent to ${name} at ${email}`);
}
// Simulate sending a deletion email
async function sendAccountDeleteEmail(email, name) {
await new Promise(resolve => setTimeout(resolve, 1000));
console.log(` Account deletion email sent to ${name} at ${email}`);
}
// Event listeners — know what's happening
worker.on('completed', (job) => {
console.log(` Job ${job.id} (${job.name}) completed`);
});
worker.on('failed', (job, err) => {
console.error(` Job ${job.id} (${job.name}) failed:`, err.message);
});
worker.on('active', (job) => {
console.log(` Job ${job.id} (${job.name}) is now active`);
});
console.log('Email worker started, waiting for jobs...');
What's happening here?
new Worker('emails', handler, options) — watches the "emails" queue. Every time a job arrives, it calls the handler function with that job.
concurrency: 5 — process up to 5 jobs simultaneously. Without this, jobs are processed one at a time. With 100 emails queued, concurrency of 5 means they're processed in batches of 5 — 5x faster.
job.name and job.data — the name and data you passed when adding the job. The worker uses the name to decide what to do.
Wire it into your API
In your signup route — call queueWelcomeEmail after saving the user. Notice: no await. Fire and forget. The user gets their response immediately.
import { queueWelcomeEmail } from '../queues/emailQueue.js';
router.post('/signup', async (req, res) => {
// Save user to database
const user = await createUser(req.body);
// Queue the email — non-blocking, returns immediately
queueWelcomeEmail(user);
// User gets their response in ~50ms
res.status(201).json({ user, accessToken, refreshToken });
});
In your delete user route:
import { queueAccountDeleteEmail } from '../queues/emailQueue.js';
router.delete('/:id', authenticate, authorize('admin'), async (req, res) => {
const result = await pool.query(
'DELETE FROM users WHERE id = $1 RETURNING *', [id]
);
// Queue deletion email — non-blocking
queueAccountDeleteEmail(result.rows[0]);
res.json({ message: 'User deleted' });
});
Run it
# Terminal 1 — your API server
node index.js
# Terminal 2 — your email worker (separate process)
node src/queues/emailWorker.js
# Terminal 3 — trigger a signup
curl -X POST http://localhost:3000/auth/signup \
-H "Content-Type: application/json" \
-d '{"name":"Prometheus","email":"p@test.com","password":"secret123"}'
Terminal 1 (API):
Queued welcome email for p@test.com ← instant
Terminal 2 (Worker):
⚙️ Job 1 (welcome-email) is now active
Processing job: welcome-email { userId: 1, email: 'p@test.com', name: 'Prometheus' }
- Welcome email sent to Prometheus at p@test.com
- Job 1 (welcome-email) completed
API responded instantly. Email sent in the background. User is happy.
What About Failures?
This is where BullMQ really shines. Suppose SendGrid goes down mid-request:
Job fires → SendGrid returns 503 → BullMQ catches the error
Wait 2 seconds → retry 1 → SendGrid still down
Wait 4 seconds → retry 2 → SendGrid recovered
Email sent on attempt 3
BullMQ handles all of this automatically. You just configure attempts and backoff — the retry logic is built in.
If all 3 attempts fail, the job moves to the failed queue in Redis. You can inspect it, fix the issue, and re-queue it manually. Nothing is lost.
Inspect Your Queues in Redis
redis-cli
# See all BullMQ keys
KEYS bull:*
# See completed jobs
ZRANGE bull:emails:completed 0 -1
# Inspect a specific job
HGETALL bull:emails:1
You'll see every field — job name, data, attempts made, timestamps, status. In production you'd build a dashboard on top of this (BullMQ has an official one called Bull Board).
The Takeaway
Synchronous: User waits 7 seconds for email + slack + analytics
Async queues: User waits 50ms. Everything else happens in background.
The rule — if a task doesn't need to finish before you respond to the user, queue it.
Welcome emails, push notifications, image resizing, PDF generation, analytics events, Slack messages — all of these belong in a queue.
Your API should do one thing: save the data and respond. Everything else is someone else's job.
Still documenting my backend journey publicly — next up is Kafka event streaming, Docker, and system design at scale.
X: [your handle]
GitHub: [your link]
Top comments (0)