📬 Node.js Job Queues: Stop Making Your API Do Everything Right Now
Picture this: a user clicks "Sign Up." Your API receives the request, creates an account in the database, sends a welcome email, generates a profile thumbnail, syncs the new user to your CRM, fires off a Slack notification to your team, and then returns a response — all in the same request handler.
That endpoint is a one-person band at a wedding. Technically doing the job. Objectively a disaster.
Job queues exist to fix exactly this. They let your API say "got it, I'll handle that" — and then hand the actual work off to a background worker that processes it separately. Your response time drops. Your users are happy. Your server doesn't collapse under its own ambition.
The Problem With Doing Everything Inline
When you shove slow operations into a request handler, a few bad things happen:
- Slow responses. Sending an email might take 500ms. That's 500ms the user is staring at a spinner.
- Cascading failures. If the email service is down, do you fail the whole signup? Do you retry? How many times? You've now written a retry loop inside an HTTP handler — congrats, you've accidentally built a job queue from scratch, badly.
- No visibility. When something fails at step 4 of 7, you have no idea what completed and what didn't. Logs are your only clue, and they're lying.
- Memory pressure. Long-running async work inside your Express process means more open handles, more RAM, more risk of taking down your API when a third-party service decides to hang indefinitely.
The fix isn't better error handling. The fix is architecture: separate the fast (HTTP response) from the slow (actual work).
Enter BullMQ
BullMQ is the go-to job queue library for Node.js. It uses Redis as its backbone, gives you retries, priorities, delays, rate limiting, and a beautiful dashboard — all out of the box. Think of it as a post office inside your backend: you drop letters (jobs) in, and postal workers (workers) deliver them on their own schedule.
Install it:
npm install bullmq ioredis
You need a running Redis instance. If you're local, docker run -p 6379:6379 redis does the trick.
The Pattern: Producer + Worker
There are two moving parts: the producer (your API, adding jobs to the queue) and the worker (a separate process consuming and executing those jobs).
The producer — your Express endpoint:
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis({ maxRetriesPerRequest: null });
const emailQueue = new Queue('emails', { connection });
app.post('/signup', async (req, res) => {
const { email, name } = req.body;
// Fast: save user to DB
const user = await db.users.create({ email, name });
// Fast: add a job to the queue (returns immediately)
await emailQueue.add('welcome-email', { userId: user.id, email, name });
// Respond in milliseconds, not seconds
res.status(201).json({ message: 'Account created!', userId: user.id });
});
Notice what's NOT in that handler: sending email. The handler is done before the email worker even wakes up. Your response time is now database write speed, not database + email + CRM + thumbnail + Slack.
The worker — a separate process:
import { Worker } from 'bullmq';
import IORedis from 'ioredis';
import { sendWelcomeEmail } from './mailer.js';
const connection = new IORedis({ maxRetriesPerRequest: null });
const worker = new Worker('emails', async (job) => {
if (job.name === 'welcome-email') {
const { userId, email, name } = job.data;
await sendWelcomeEmail({ to: email, name });
console.log(`Welcome email sent to ${email}`);
}
}, {
connection,
attempts: 3, // retry up to 3 times on failure
backoff: {
type: 'exponential',
delay: 2000, // wait 2s, then 4s, then 8s between retries
},
});
worker.on('failed', (job, err) => {
console.error(`Job ${job.id} failed after all retries:`, err.message);
});
Run this worker as a completely separate Node process (node worker.js). It connects to the same Redis queue and chews through jobs as they arrive. If the email service blips, BullMQ retries automatically — with exponential backoff, because hammering a broken service every 100ms is how you make friends.
What to Offload vs. What to Keep Inline
Not everything belongs in a queue. Here's a simple mental model:
Queue it if:
- It touches a third-party service (email, SMS, Stripe, Slack, S3)
- It's CPU-intensive (image resizing, PDF generation, report crunching)
- It can tolerate a few seconds of delay without breaking UX
- Failure should trigger retries, not a 500 error
Keep it inline if:
- The response depends on the result (e.g., validating a coupon code)
- It's a simple database read/write that takes under 50ms
- Eventual consistency would be confusing or incorrect here (e.g., charging a card)
The rule of thumb: if the user doesn't need to see the result of the work to continue their journey, queue it.
Bonus: Built-in Observability
One underrated perk of BullMQ is that every job has a lifecycle: waiting → active → completed / failed. You can query job states, inspect payloads, and see retry history — all through Redis. There's even Bull Board, a UI dashboard you can bolt onto your Express app in about 10 minutes, so you can watch jobs flowing through in real time without squinting at logs.
The Bigger Picture
Job queues are one of those patterns that feel like over-engineering until the day your email provider has a 30-second timeout and your entire API grinds to a halt. Then you get it. The goal isn't complexity for its own sake — it's decoupling fast paths from slow ones so your system degrades gracefully instead of catastrophically.
Start small: pick the one slowest thing in your most-used endpoint and move it to a queue. Measure the response time difference. You'll never look back.
Ready to stop blocking your API on things that can wait? Pick one endpoint in your app that does "extra" work after the main action. Move that extra work into a BullMQ job this week. Your users (and your future on-call self) will thank you.