Engineering Notes
Home/Backend/Frameworks/Background Jobs
Backend · Frameworks

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.

⚙️ Backend● Intermediate⏱ 6 min read▶ 2 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 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

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:

WorkWhy not in the request
sending emails / push notificationsthird-party latency + failures shouldn't fail the user's action
image/video processing, PDF generationseconds to minutes of CPU
calling webhooksthe receiver may be slow or down; needs retries
report exports, bulk importslong-running, would hit HTTP timeouts
periodic cleanup, billing runsnot triggered by a request at all (scheduled)
ts
// 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

text
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

ts
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:

TechniqueExample
natural idempotency"set status = shipped" (not "toggle")
idempotency key / dedup tablerecord job:welcome:u_1 done in a table with a unique constraint before sending
check-then-act on current stateskip if invoice.paid_at is already set
provider idempotency keyspass 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

ts
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.

sql
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.

The API enqueues a job into the wait list; a worker atomically moves it to active and holds a renewing lock; success moves it to completed, failure schedules a delayed retry with backoff, and exhausted attempts land in the failed set
The API enqueues a job into the wait list; a worker atomically moves it to active and holds a renewing lock; success moves it to completed, failure schedules a delayed retry with backoff, and exhausted attempts land in the failed set

⚠️ Common Pitfalls

  1. Putting whole objects in the payload — they go stale; enqueue IDs and load fresh data in the worker.
  2. Non-idempotent handlers — duplicate emails, double charges after a retry or stalled-job recovery.
  3. Retrying permanent errors — wastes capacity and delays the DLQ alert.
  4. Enqueue outside the DB transaction — lost or phantom jobs (use an outbox).
  5. Running workers inside the API process without limits — CPU-heavy jobs slow down HTTP latency.
  6. No monitoring of queue depth — failures pile up silently.
  7. setTimeout/in-memory queues for important work — lost on every restart or deploy.

📋 Quick Reference

NeedUse
durable job queue (Node)BullMQ (Redis), pg-boss (Postgres), SQS
delay / scheduledelay, repeat: { pattern }
retriesattempts + exponential backoff
dedupstable jobId / idempotency key
atomic "write + enqueue"transactional outbox
scale processingmore workers / concurrency
failed after all retriesdead-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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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).

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • HTTP pattern? Return 202 Accepted with a Location to 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.

1. Why must job handlers be idempotent?
At-least-once.
2. DB commit succeeds but enqueue fails. Pattern that fixes it?
Atomic with the business write.
3. A job fails 5 times with a permanent error. Where does it go?
Don't lose or loop.
4. Key metric for queue health?
Backlog growth = trouble.