Background Jobs
Slow or unreliable work belongs outside the request. See how a queue absorbs bursts, design workers for at-least-once delivery with retries and dead letters, and fix the dual-write problem with an outbox.
🎯 What you'll learn
- Why move work out of the request
- Producers, queues, workers and BullMQ
- At-least-once delivery, retries, backoff and dead letters
- The dual-write problem, the outbox, and operating workers
📚 Before you start
1Why Move Work Out of the Request
A request handler should do the minimum needed to answer the client. Anything slow, retryable, or not needed for the response belongs in a background job:
| Work | Why not in the request |
|---|---|
| sending emails / push notifications | third-party latency + failures shouldn't fail the user's action |
| image/video processing, PDF generation | seconds to minutes of CPU |
| calling webhooks | the receiver may be slow or down; needs retries |
| report exports, bulk imports | long-running, would hit HTTP timeouts |
| periodic cleanup, billing runs | not triggered by a request at all (scheduled) |
// before: user waits for the email provider, and a provider outage fails signup
app.post("/signup", async (req, res) => {
const user = await users.create(req.body);
await mailer.sendWelcome(user.email); // 800ms, sometimes throws
res.status(201).json(user);
});
// after: enqueue and return; a worker sends the email with retries
app.post("/signup", async (req, res) => {
const user = await users.create(req.body);
await queue.add("send-welcome-email", { userId: user.id }, { attempts: 5, backoff: { type: "exponential", delay: 2000 } });
res.status(201).json(user);
});2The Moving Parts
producer (API) ──add job──► queue (Redis / RabbitMQ / SQS / Postgres) ──► worker(s) ──► side effect
▲ │
└──── retry with backoff / DLQ ◄─────┘ on failure- Producer — enqueues a job: a name + a small JSON payload (IDs, not whole objects).
- Queue / broker — durable storage of pending jobs (
distributed-systems/message-brokers/). - Worker — a separate process (or several) that pulls jobs and runs handlers. Scale workers independently from the API.
- Scheduler — creates jobs on a cron schedule (
"0 3 * * *"for a nightly cleanup).
3BullMQ in Practice
import { Queue, Worker } from "bullmq";
const connection = { host: "localhost", port: 6379 };
export const emailQueue = new Queue("email", { connection });
// producer
await emailQueue.add("welcome", { userId: "u_1" }, {
jobId: "welcome:u_1", // dedup: the same jobId isn't added twice
attempts: 5,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: 1000,
});
// worker (separate process)
new Worker("email", async (job) => {
const user = await users.findById(job.data.userId); // load fresh data
if (!user) return; // deleted meanwhile → nothing to do
await mailer.sendWelcome(user.email);
}, { connection, concurrency: 10 });
// repeatable job
await emailQueue.add("digest", {}, { repeat: { pattern: "0 8 * * MON" } });NestJS wraps this with @nestjs/bullmq (@Processor("email"), @InjectQueue("email")); the concepts are identical.
4Delivery Semantics: Design for "At Least Once"
Workers can crash after doing the work but before acknowledging the job, so a job may run more than once. Every handler must be idempotent — running it twice has the same effect as once:
| Technique | Example |
|---|---|
| natural idempotency | "set status = shipped" (not "toggle") |
| idempotency key / dedup table | record job:welcome:u_1 done in a table with a unique constraint before sending |
| check-then-act on current state | skip if invoice.paid_at is already set |
| provider idempotency keys | pass Idempotency-Key: <jobId> to Stripe |
distributed-systems/message-brokers/09-delivery_guarantees goes deeper.
5Retries, Backoff and Dead Letters
- Retry transient failures (timeouts, 5xx, rate limits) with exponential backoff + jitter (
distributed-systems/fundamentals/06-retry_with_exponential_backoff). - Don't retry permanent failures (validation errors, 4xx "user not found") — mark the job failed immediately.
- After the last attempt, move the job to a dead-letter queue (or "failed" set) with its error, alert on it, and provide a way to inspect and replay it.
6The Dual-Write Problem and the Outbox
await db.orders.insert(order); // 1. commit to DB
await queue.add("order-created", { id }); // 2. enqueue — what if the process dies between 1 and 2?If the process crashes between the two steps, the order exists but the job never does. If you enqueue first, the job may run for an order that was rolled back. The transactional outbox fixes it: write the job into an outbox table in the same DB transaction as the order; a relay process reads the outbox and publishes to the queue, deleting/marking rows after success.
BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox (id, type, payload) VALUES (gen_random_uuid(), 'order-created', '{"orderId": 81}');
COMMIT;7Operating Workers
- Graceful shutdown: on
SIGTERM, stop taking new jobs, let in-flight jobs finish (with a deadline), then exit. - Visibility: queue depth, job age (oldest waiting), failure rate, duration — alert when the backlog grows (
devops-cloud/observability/05-monitoring_and_alerting). - Timeouts: a stuck job holds a concurrency slot forever; set per-job timeouts.
- Priorities / separate queues: don't let a 50,000-job bulk import delay password-reset emails.
Under the HoodHow a Redis-Backed Queue Hands Out Jobs▾
BullMQ stores each queue as a handful of Redis structures: a wait list (LIST of job IDs), an active list, a delayed sorted set (scored by run-at timestamp), completed/failed sets, and a HASH per job with its data and attempt count.
A worker fetches with a blocking move — conceptually BLMOVE wait active — so the job ID moves atomically from wait to active: two workers can never take the same job. While processing, the worker holds a lock key for that job with a short TTL and keeps renewing it. If the worker process dies, renewals stop, the lock expires, and a periodic stalled-job check moves the job back to wait — which is exactly why a job can run twice and handlers must be idempotent.
Failures increment the attempt counter; if attempts remain, the job goes into the delayed sorted set with score now + backoff(attempt), and a timer promotes due delayed jobs back to wait. All multi-step transitions run as Lua scripts inside Redis, which execute atomically, so no other client can observe a half-moved job.
⚠️ Common Pitfalls
- Putting whole objects in the payload — they go stale; enqueue IDs and load fresh data in the worker.
- Non-idempotent handlers — duplicate emails, double charges after a retry or stalled-job recovery.
- Retrying permanent errors — wastes capacity and delays the DLQ alert.
- Enqueue outside the DB transaction — lost or phantom jobs (use an outbox).
- Running workers inside the API process without limits — CPU-heavy jobs slow down HTTP latency.
- No monitoring of queue depth — failures pile up silently.
setTimeout/in-memory queues for important work — lost on every restart or deploy.
📋 Quick Reference
| Need | Use |
|---|---|
| durable job queue (Node) | BullMQ (Redis), pg-boss (Postgres), SQS |
| delay / schedule | delay, repeat: { pattern } |
| retries | attempts + exponential backoff |
| dedup | stable jobId / idempotency key |
| atomic "write + enqueue" | transactional outbox |
| scale processing | more workers / concurrency |
| failed after all retries | dead-letter queue + alert + replay |
🧠 Key takeaways
- Move slow/unreliable work (emails, PDFs, webhooks, video) to queues + workers; respond fast with 202 when appropriate.
- Delivery is at least once: make jobs idempotent; use retries with exponential backoff and a dead-letter queue.
- Avoid dual writes (DB + queue) with the transactional outbox.
- Operate workers: concurrency limits, graceful shutdown, stalled-job detection, metrics on queue depth and age.
🎤 Interview Q&A
Q1When do you use background jobs?core▾
For work that's slow, retryable, scheduled, or not needed for the immediate response: emails, reports, image processing, syncing with third parties, cleanup tasks.
- Libraries? BullMQ (Node), Hangfire/Quartz (.NET), Celery/RQ (Python), Sidekiq (Ruby).
Q2How do you make background jobs reliable?core▾
Persist jobs in a durable queue, make them idempotent, retry with backoff, cap attempts and dead-letter failures, set timeouts, and monitor queue depth and failure rates.
- Why idempotent? Jobs can run more than once after crashes or timeouts.
Q3How do you schedule recurring jobs in a multi-instance deployment?senior▾
Use a scheduler with distributed locking or leader election so only one instance runs each schedule (Hangfire, BullMQ repeatable jobs, Quartz clustering), or an external scheduler (Kubernetes CronJob, EventBridge Scheduler).
- Naive cron risk? Every instance runs the job simultaneously.
Q4In-process hosted services vs separate workers?core▾
In-process (BackgroundService, setInterval) is simple but competes with request handling and dies with the web process. Separate worker processes scale independently and isolate failures.
- When is in-process fine? Light, non-critical periodic tasks.
Q5How do you report job progress to users?core▾
Store status and progress in the DB/cache, expose a status endpoint, and push updates via WebSockets/SSE or notifications when done.
- HTTP pattern? Return 202 Accepted with a
Locationto the job status resource.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.