How to design reliable background workers in Node.js — queue choice, idempotency, retries and dead-letter queues, monitoring, and the mistakes that cost me an all-nighter so they do not have to cost you one.
- Published
- Reading
- 3 min
- Author
- Yaroslav Tsarenko
Every real Node.js backend eventually needs a job queue. The moment you have an endpoint that sends an email, generates a PDF, calls an external API, or updates 40,000 rows, doing it inside the request is a mistake waiting to happen.
Here is how I set up background workers for production apps — the same pattern I used for notification pipelines and PDF generation on a US e-commerce platform.
1. Pick a queue based on ops budget, not benchmarks
- BullMQ (Redis) — fast, mature, great DX. Requires a managed Redis. My default for most Node.js apps.
- pg-boss / Graphile Worker (PostgreSQL) — one less moving part if you already have Postgres. Solid at low-to-medium throughput.
- AWS SQS / Google Pub/Sub — the right answer when you already live in that cloud and want at-least-once with almost no ops.
Do not pick the "trendiest" queue. Pick the one your team can debug on a Sunday.
2. The two rules every job must obey
Rule 1: idempotency. Assume every job will run twice. Design your handler so running it twice produces the same result as running it once. Use a natural key (order ID + status transition) instead of INSERT blindly.
Rule 2: no hidden state. The job payload must contain everything the worker needs. Do not read a mutable database row and assume it looked the same when the job was enqueued.
3. Retries done right
Default to exponential backoff with jitter:
await queue.add("send-invoice-email", payload, {
attempts: 5,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: 1000,
removeOnFail: 5000,
});But not every failure is worth retrying. Split errors into:
- Transient (network blip, provider 5xx) → retry.
- Permanent (invalid email, missing record) → do not retry, send to a dead-letter queue for a human to see.
Wrap your handler:
class PermanentError extends Error {}
worker.on("failed", (job, err) => {
if (err instanceof PermanentError) {
// route to DLQ, alert
}
});4. Dead-letter queues save your night
A DLQ is not "extra work" — it is the difference between "we lost 30 emails silently" and "we saw 30 emails go to DLQ and reprocessed them by lunch." Every non-trivial queue needs one.
5. Concurrency and back-pressure
Do not set concurrency to a big number and hope. Model it:
- If a job hits an external API rate-limited to 10 rps, run at most 10 concurrent workers.
- If a job holds a database connection,
concurrency * jobs_per_second * job_durationmust be smaller than your pool.
I keep a spreadsheet with these numbers for every queue. Boring. Works.
6. Observability from day one
Log with structure:
worker.on("completed", (job) => {
logger.info({ event: "job.completed", queue: "email", jobId: job.id, durationMs: job.finishedOn! - job.processedOn! });
});Ship metrics to Grafana or the built-in dashboards of your provider (Vercel, Datadog, Sentry). The three metrics you cannot live without: queue depth, job age at pickup, and failure rate per queue.
7. The graceful shutdown gotcha
If your workers run inside containers, they will get SIGTERM during deploys. If you do not handle it, jobs die mid-processing and become DLQ or duplicate:
process.on("SIGTERM", async () => {
await worker.close();
process.exit(0);
});BullMQ's worker.close() waits for in-flight jobs to finish. Give it long enough in your orchestrator (Kubernetes terminationGracePeriodSeconds, ECS stopTimeout).
8. Testing background jobs
Unit-test the handler as a pure function of payload. Integration-test the queue path with a real (or in-memory) Redis. Do not mock the queue library — every mock I have seen hides a real bug.
9. A pattern I keep reusing: outbox
For jobs that must fire after a database write (send email when order is created), use the outbox pattern:
- In the same transaction as the order insert, insert a row into
outbox_events. - A worker polls
outbox_events, enqueues into the real queue, marks the row processed.
You get exactly-once semantics across your database + queue boundary without distributed transactions.
Wrapping up
Background workers are the boring, load-bearing spine of every backend I have shipped. Getting them right is not glamorous, but it is the thing that separates "our platform is stable" from "our platform is on fire every Tuesday."
If you have a Node.js codebase where synchronous requests are doing work that belongs in a queue — reach out. I have moved PDF generation, notifications and export jobs out of the request path more times than I can count.