Part 6 · Async & Event-Driven Systems
Background Jobs, Retries & Dead-Letter Queues
Background jobs are easy to start and surprisingly easy to get wrong: poison jobs, retry storms, cron that runs twice, deploys that kill half-done work. How to design jobs, retry only what can succeed, and route what can't to a dead-letter queue you actually watch.
On this page
The Problem
A background job is a piece of work that runs outside the web request, so the user does not wait for it. A worker is a program that picks jobs from a queue and runs them. Your app has a lot of work that should not happen inside a web request:
- sending welcome emails,
- generating a 40-page PDF report,
- transcoding uploaded videos,
- cleaning up expired sessions every night,
- syncing data to a partner's API that is slow and often down.
You move it all into background jobs, and things get better. Then problems start:
- one broken job fails forever and blocks everything behind it,
- a partner API outage causes millions of retries that make the outage worse,
- a nightly cleanup job runs twice because two servers both thought they should run it,
- a deploy stops a video conversion in the middle.
Background jobs are easy to start, but also easy to get wrong. This post shows how to run them reliably, and what to do with jobs that keep failing.
The Core Idea
Think about a laundry service.
- Customers drop off bags (jobs) at the counter and get a receipt immediately (the request returns fast).
- Washers (workers) take bags from the pile and process them.
- Express bags go in a separate pile, so they are not stuck behind huge wedding loads (priority queues).
- If a machine jams, the staff try again later. Not at once, and not forever.
- If a bag cannot be washed (a strange fabric, no label), it goes to a "problem shelf" where a manager decides what to do. It does not stay in the main pile and block the others. That shelf is the dead-letter queue (a queue that holds jobs which failed too many times).
How It Works
Task queues vs message brokers
- A message broker (RabbitMQ, SQS, Redis, Kafka) is a server that stores messages and passes them from senders to receivers.
- A task queue framework is a library that runs jobs on top of a broker. It adds useful features: retries, scheduling, priorities, job status and dashboards.
| Framework | Language | Typical broker |
|---|---|---|
| Celery | Python | RabbitMQ, Redis |
| Sidekiq | Ruby | Redis |
| BullMQ | Node.js | Redis |
| RQ | Python | Redis |
| Hangfire | .NET | SQL Server, Redis |
| Laravel Queues / Horizon | PHP | Redis, SQS, database |
| Cloud Tasks / SQS + Lambda | Any | Managed |
// BullMQ example: enqueue in the request, process in a workerawait emailQueue.add("welcome", { userId: 42 }, { attempts: 5, backoff: { type: "exponential", delay: 2000 } });new Worker("email", async (job) => { await sendWelcomeEmail(job.data.userId); // should be idempotent (post 37)!});Designing good jobs
1. Keep job payloads small: pass IDs, not whole objects. The payload is the data you put inside the job message.
- +{ "userId": 42 }
- +The worker loads fresh data when it runs
- +Tiny queue footprint
- −{ "user": { …200 fields… } }
- −Stale by the time the job runs
- −Bloats the queue and leaks data into logs
2. Make every job idempotent. Idempotent means running it many times gives the same result as running it once. Jobs will run more than once, because of retries, crashes and redelivery (post 37). "Send welcome email" should first check "already sent?". "Charge invoice" should use an idempotency key (a unique label that lets the receiver spot a repeat).
3. Make big jobs resumable, or split them. A job that handles 1 million rows should work in chunks (for example 1,000 rows each) and save its progress. Or it can fan out into many small jobs. Then a crash means redoing 1,000 rows, not 1 million.
4. Set timeouts. A timeout is a maximum time you allow. A job that is stuck forever holds a worker forever. Give each job type a sensible maximum run time.
5. Separate queues by priority and duration.
This way, a flood of slow report jobs never delays OTP emails (OTP means one-time password, a short code sent to a user). Give each queue its own workers, or give them weighted priority (a bigger share of worker time for important queues).
Workers in practice
- Concurrency is how many jobs one worker runs at the same time (with threads, async code or processes, post 4). Tune it to your work. CPU-bound jobs (they spend time calculating) get a low number. I/O-bound jobs (they spend time waiting for networks or disks) get a higher number.
- Prefetch is how many jobs a worker takes from the queue in advance. If it is too high, one worker keeps many jobs while other workers sit idle.
- Visibility timeout and heartbeat. When a worker takes a job, the queue hides it for a set time. This is the visibility timeout, or lease. A heartbeat is a regular "I am still alive" signal. Long jobs must extend their lease (post 18). Otherwise the queue thinks the worker died and gives the job to someone else, while the first worker is still running it.
- Graceful shutdown means stopping in a clean way. On deploy, workers should stop taking new jobs, finish (or safely give back) their current jobs, then exit. Set a grace period (the time allowed to finish) that is long enough for typical jobs.
- Auto-scaling means adding or removing workers by itself. Base it on queue depth (how many jobs wait) and job age, not only on CPU.
Scheduled and recurring jobs
- Delayed jobs run later: "send a reminder in 24 hours", "retry in 5 minutes". Most frameworks can schedule a job for later.
- Recurring jobs (cron) run again and again on a time rule. Cron is the classic Unix scheduler. Example: "every night at 2 AM, clean up expired sessions".
The distributed cron problem: if you run the cron schedule on every server, the job runs N times (once per server). Fixes:
- a single scheduler process (with a standby one ready) that adds the job to the queue once,
- leader election (the servers vote so that only one is in charge) or a distributed lock (a lock that all servers share, so only one can hold it). Examples are a Redis lock with a TTL (time to live: the lock frees itself after a set time) or a database advisory lock,
- platform features like Kubernetes CronJobs or cloud schedulers,
- and, as always, making the job idempotent, so an accidental double run does no harm.
Workflow engines for multi-step jobs
Some "jobs" are really long processes with many steps: "charge card → reserve stock → book courier → send confirmation". If booking fails, you must refund the card. Building this as a chain of queued jobs, with your own state tracking, gets messy.
A workflow engine is a tool that runs multi-step processes for you. It keeps the state, and handles retries, timeouts and compensation (undo steps, like the refund):
- Temporal (created by engineers who originally built Uber's Cadence),
- AWS Step Functions,
- Apache Airflow (for data pipelines),
- Azure Durable Functions.
These tools are closely linked to sagas (a saga is a long process made of steps, where each step has an undo step, post 39).
Retries
Transient vs permanent failures
A retry means running a failed job again. Before you retry, ask: will trying again help? A transient failure is short and goes away by itself. A permanent failure stays until someone fixes it.
| Failure | Type | Retry? |
|---|---|---|
| Timeout, connection reset, 503, 429 | Transient | ✅ Yes, with backoff |
| Database deadlock (two transactions wait for each other) / serialization failure | Transient | ✅ Yes |
| 400 Bad Request, invalid data, missing record | Permanent | ❌ No: retrying won't fix it |
| 401/403 (bad credentials) | Usually permanent | ❌ Alert humans |
| Bug in your code | Permanent (until fixed) | ❌ Park it, fix, then replay |
Retrying permanent failures wastes resources and delays everything else.
Retry with exponential backoff and jitter
Backoff means waiting before a retry. Exponential backoff makes the wait longer each time, for example 1s, 2s, 4s, 8s. Do not retry at once, and do not retry forever:
Randomness breaks the lock-step. The same number of retries arrives as a gentle, even trickle.
Add random jitter. Jitter is a small random change to the wait time. For example, wait between 0 and 8 seconds instead of exactly 8. Then thousands of failed jobs do not all retry at the same instant and overload the service that is trying to recover. (Post 41 covers this in depth.)
Retry storms
A retry storm is when many retries together overload a service that is already weak. It happens when a downstream service (a service you call, like a partner API) goes down:
- every job fails and retries,
- the retries add load exactly when the service is weakest,
- its recovery is delayed, which causes more failures and more retries.
How to protect yourself:
- cap attempts per job (set a maximum number),
- use backoff with jitter,
- keep retry budgets (a limit on total retries, for example "retries may add at most 10% extra load"),
- use circuit breakers. A circuit breaker is like an electric fuse: after many failures it stops calls to that service for a while (post 42),
- rate-limit workers that call weak third-party services (a rate limit caps how many calls per second are allowed).
Retries and ordering
Queues with strict order (Kafka partitions, FIFO queues) have a special problem. Say message #5 fails and you retry it in place. Then every message behind it must wait. This is called head-of-line blocking: one stuck item at the front of the line blocks all the others. The common fix is retry topics (a topic is a named message stream in Kafka):
- 1Main topicconsumer processes normally
- 2retry-1m topicre-consumed after 1 minute
- 3retry-10m topicre-consumed after 10 minutes
- 4DLQ topicparked, alerted on, replayed after a fix
The main topic keeps flowing while failed messages wait in separate topics with longer and longer delays. This breaks strict order for the failed messages. If order really matters, hold back the later messages that have the same key.
Dead-letter queues (DLQs)
A dead-letter queue (DLQ) is a separate queue that holds messages after they have failed too many times (or are clearly invalid). It is the "problem shelf".
- 1Worker picks up the jobattempt 1
- 2Fails — transient?retry with backoff + jitter, up to N times
- 3Still failingmove to the dead-letter queue with the error, stack trace and attempt count
- 4Alert firesDLQ size or growth crossed a threshold
- 5Engineer inspectsfixes the bug or the bad data
- 6Redrivereplay to the main queue, rate-limited, with idempotent consumers
Why DLQs matter:
- Poison messages (messages that always fail) stop blocking workers or wasting their time.
- Nothing is lost without a sign. Failed work is kept so you can look at it.
- After you fix the bug or the data, you can replay (send again) the messages.
Built-in support:
- Amazon SQS: a "redrive policy" moves a message to a DLQ after
maxReceiveCountfailed receives. There is also a "redrive" feature to move messages back. - RabbitMQ: dead-letter exchanges. Rejected or expired messages are sent to another exchange.
- Kafka: there is no built-in DLQ for normal consumers. You build one with retry and DLQ topics (Kafka Connect sink connectors and many frameworks give you this).
- Task frameworks: Sidekiq's "Dead" set, Celery and BullMQ failed-job lists.
DLQ best practices:
- Alert on DLQ size and growth. A DLQ that nobody watches is a black hole.
- Store context with each dead message: the error, the stack trace (the list of function calls at the error), the attempt count and the times.
- Build tools to look at, fix and safely replay messages, one by one or in bulk.
- Replay with care. Make sure the bug is fixed and consumers are idempotent. Replaying 50,000 messages at once can overload systems, so rate-limit it.
- Set retention (how long messages are kept). DLQ messages expire too. SQS keeps them for up to 14 days, so act within that time.
What to monitor
- Queue depth (jobs waiting) and oldest job age. Age is often the better signal: "OTP jobs are 3 minutes old" is an incident.
- Throughput (jobs processed per second) and failure rate per job type.
- Retry counts and DLQ size.
- Job duration, as p50 and p99. p50 is the typical time (half the jobs are faster). p99 is the slow end (99 of 100 jobs are faster). Watch it to catch slow jobs before they time out.
Trade-offs
- Background jobs: fast responses and resilience, but results arrive later, and you need workers, a queue and monitoring.
- Aggressive retries: recover quickly from blips, but risk retry storms and duplicate side effects.
- Few retries: low load, but more work ends up in the DLQ needing manual handling.
- Retry topics: keep the main flow moving, but more topics to manage and relaxed ordering.
- Workflow engines: great for complex multi-step processes, but another system to learn and run.
In the Real World
GitHub and Resque. GitHub created Resque, a Ruby job queue that stores jobs in Redis. It used it for its background work and open-sourced it in 2009. It inspired many later tools (including Sidekiq). It shows how central background jobs are to web applications.
Uber's retries and DLQs on Kafka. Uber wrote about building reliable reprocessing on Kafka. They used retry topics with longer and longer delays and dead-letter topics. So failed messages do not block real-time pipelines, and can be replayed after a fix.
Slack's job queue. Slack has written about scaling its job queue. The queue runs work like message indexing, notifications and link previews. An incident overloaded its Redis-based queue. Slack then added Kafka in front as a durable buffer (a safe place to hold jobs). It is a good example of treating the job system itself as critical infrastructure.
Temporal at many companies. Temporal grew out of Cadence, which was built at Uber to run long workflows reliably. Companies now use it for order fulfilment, payments, onboarding flows and infrastructure automation. In these cases retries, timeouts and compensation must be handled with care.
Email and OTP delivery. Transactional email and SMS systems (those that send messages caused by a user action) usually use separate high-priority queues for OTPs and password resets. They watch job age closely, because an OTP that arrives after it has expired is as bad as no OTP.
Interview Questions
Q1Which failures should a job retry?
Transient ones: timeouts, connection resets, 429 and 503 responses, deadlocks and serialization failures. Not permanent ones like 400 validation errors, missing records, bad credentials or code bugs — those should go to a DLQ or alert a human, because retrying only wastes capacity.
Q2What is a retry storm, and how do you prevent it?
When a dependency fails, every client retries and the extra load keeps it down. Prevent it with capped attempts, exponential backoff with jitter, retry budgets, circuit breakers and rate limits on calls to fragile services.
Q3What's a dead-letter queue for?
It holds messages that failed repeatedly or are invalid, so poison messages stop blocking or wasting workers and nothing is silently lost. Alert on it, store error context with each message, and replay after fixing the cause.
Q4How do you stop a cron job from running on every server?
Run the schedule in a single scheduler (with a standby), use leader election or a distributed lock with a TTL, or use Kubernetes CronJobs or a cloud scheduler — and make the job idempotent so an accidental double run is harmless.
Q5A long-running job is processed twice even though it never failed. Why?
Its visibility timeout or lease expired while it was still running, so the queue assumed the worker died and gave the job to another worker. Extend the lease with heartbeats, set timeouts longer than the job, or split it into smaller chunks.
Q6How do workers handle deploys safely?
On SIGTERM they stop taking new jobs, finish or safely release in-flight ones within a grace period, then exit. Long jobs should checkpoint progress so they can resume after a restart.
Key Takeaways
- Background jobs keep requests fast. Use task frameworks (Celery, Sidekiq, BullMQ…) on top of a broker.
- Design jobs to be small (pass IDs), idempotent, chunked, time-limited, and separated into priority queues.
- Handle graceful shutdown, lease extension for long jobs, and distributed cron (a single scheduler or locks, plus idempotency).
- Retry only transient failures, with exponential backoff + jitter, capped attempts and retry budgets. Beware retry storms.
- Send repeatedly failing messages to a dead-letter queue. Alert on it, inspect it, fix the cause, and replay safely.
Further Reading
- The System Design Primer on GitHub (section "Task queues")
- The Celery documentation and the Sidekiq wiki ("Best Practices")
- The Amazon SQS Developer Guide section on dead-letter queues
- The RabbitMQ documentation on dead letter exchanges
- Uber's engineering blog post "Building Reliable Reprocessing and Dead Letter Queues with Apache Kafka"
- The Temporal documentation
- The AWS Builders' Library article "Avoiding insurmountable queue backlogs"
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related series