system-design

Retries, Timeouts, and Jitter

Retries recover from transient failures and cause outages. The difference is entirely in the details — and the details are backoff, jitter, and budgets.

Prerequisites: Resilience Patterns, Idempotency Time to read: ~22 minutes


The problem

A call fails. Networks drop packets, instances restart, a leader election takes two seconds. Most failures are transient — retrying works.

So you retry. And then this happens:

Service B degrades and starts failing 50% of requests.
Every client retries 3 times.
B's effective load goes from 1,000 rps to ~2,500 rps.
B degrades further → failure rate rises → more retries → 4,000 rps
B collapses completely.

🚨 The retries caused the outage. B was struggling and would have recovered; the retry storm killed it. This is one of the most common patterns in real incident reports.

The rule to internalize: a retry is not free. It is additional load applied to a system that is already failing.


Timeouts come first

You cannot retry without a timeout — you’d wait forever on the first attempt.

📐 Setting the value:

Timeout ≈ p99.9 latency of the dependency + margin

🚨 Measure, don’t guess. If your dependency’s p99.9 is 200 ms, a 30-second timeout means a degraded dependency ties up a thread 150× longer than it should.

Timeouts must shorten as you go inward:

Client 30s → Gateway 25s → Service A 20s → Service B 15s → Database 10s

Otherwise the database is still executing a query for a request the client abandoned twenty seconds ago — burning resources for a result that will be discarded. This is a real and common source of wasted capacity.

🎙️ The deadline propagation refinement, which is a strong detail to mention: instead of each hop having a fixed timeout, pass the remaining budget with the request. gRPC does this natively.

Client sends: deadline = now + 30s
Gateway (5s elapsed) forwards: deadline = now + 25s
Service A (3s elapsed) forwards: deadline = now + 22s
→ every hop knows the real remaining budget, and can refuse work it can't finish in time

Retry strategies

Fixed interval — don’t

attempt 1 → fail → wait 1s → attempt 2 → fail → wait 1s → attempt 3

🚨 The problem: if the dependency is overloaded, you’re hammering it at a constant rate and giving it no room to recover.

Exponential backoff

attempt 1 → wait 1s → attempt 2 → wait 2s → attempt 3 → wait 4s → attempt 4 → wait 8s

✅ Gives the dependency exponentially increasing breathing room. Total load from retries decays rapidly.

⚠️ Still has a serious flaw on its own.

🚨 Exponential backoff with jitter — the correct answer

Backoff alone causes synchronization. All clients that failed at the same instant retry at the same instant:

1,000 clients fail at t=0
All wait exactly 1s → all retry at t=1  → 1,000 simultaneous requests → all fail
All wait exactly 2s → all retry at t=3  → 1,000 simultaneous requests → all fail

A thundering herd, repeating forever, in perfect lockstep. The backoff spread out the average load and did nothing about the peaks — and peaks are what kill you.

Jitter randomizes the delay:

# Full jitter — AWS's recommendation, and the best performer in their testing
delay = random.uniform(0, min(cap, base * 2 ** attempt))

# Equal jitter — half fixed, half random. Slightly more predictable.
temp = min(cap, base * 2 ** attempt)
delay = temp / 2 + random.uniform(0, temp / 2)

# Decorrelated jitter — good for long-running retry loops
delay = min(cap, random.uniform(base, previous_delay * 3))

📐 AWS published simulations comparing these. Full jitter performed best — it minimized both total work and completion time, because it spreads retries evenly across the window instead of clustering them.

🚨 “Exponential backoff with jitter” is the phrase to use. Saying just “exponential backoff” misses the more important half — and interviewers who’ve operated systems will notice.

def retry(fn, max_attempts=5, base=0.1, cap=30):
    for attempt in range(max_attempts):
        try:
            return fn()
        except RetryableError:
            if attempt == max_attempts - 1:
                raise
            time.sleep(random.uniform(0, min(cap, base * (2 ** attempt))))

What to retry — and what not to

🚨 Retrying the wrong thing wastes resources and can corrupt data.

Response Retry? Why
Connection refused / reset Transient; the request likely never arrived
Timeout ⚠️ Only if idempotent Ambiguous — it may have succeeded
429 Too Many Requests Honour Retry-After, don’t guess
500 Internal Server Error Possibly transient
502 / 503 / 504 Upstream problem, likely transient
400 Bad Request Malformed. It will never succeed.
401 / 403 Refresh the credential, don’t blindly retry
404 It doesn’t exist
409 Conflict ⚠️ Only after resolving the conflict
422 Unprocessable Semantically invalid

🚨 Retrying 4xx is a specific and common bug. The request will never succeed, so you burn capacity on guaranteed failures — and if the 4xx was caused by overload-induced misbehaviour, you’ve made it worse.

The timeout row is the interesting one. A timeout is ambiguous — the operation may have completed. Retry only if the operation is idempotent, which for anything with side effects means you need an idempotency key. → Idempotency


Retry budgets and amplification

🚨 Retry amplification: the multiplier nobody calculates

Retries at every layer of a call chain multiply:

Client retries 3× → Gateway retries 3× → Service A retries 3× → Service B

One user request can become 3 × 3 × 3 = 27 requests to Service B.

📐 With a five-layer chain and 3 retries each, one request becomes 243. During a partial outage, this is what converts a degradation into a total collapse.

The fix: retry at one layer only. Usually the outermost (closest to the user) or the innermost (closest to the failure), never both. Decide deliberately and document it.

Retry budgets

A global cap on retries as a fraction of total traffic, rather than a per-request count.

Retry budget: retries ≤ 10% of successful requests over a rolling window.

Normal operation:      few failures → retries well under budget → all retried ✅
Dependency degraded:   many failures → budget exhausted → additional retries REJECTED
                       → load stays bounded → the dependency can recover

🚨 This is the mechanism that actually prevents retry storms, and it’s what per-request retry counts cannot do. Per-request limits bound one client’s behaviour; a budget bounds the aggregate, which is what the failing dependency experiences.

It’s the correct answer to “how do you stop retries from causing a cascade?” and most candidates only know backoff and jitter.

Implemented in gRPC (retry throttling), Envoy, Finagle, and most service meshes.

Circuit breakers and retries together

A tripped circuit breaker stops retries entirely — which is the strongest form of retry limiting. Use both: budgets bound the retry rate during partial degradation; the breaker cuts it to zero during total failure.


Where retries should live

Client-side (in the caller): the caller knows whether the operation is idempotent and what the business impact of failure is. It’s the most common and usually correct place.

Service mesh / sidecar: configured centrally, applied uniformly, no application code. → Service Mesh 🚨 Watch for double-retrying — if both the application and the mesh retry, you get multiplication without anyone intending it. This is a real and confusing production issue.

Load balancer: can retry an idempotent request on a different backend. Only safe for idempotent methods, which is why L7 load balancers default to retrying GET but not POST.

Message queue: the broker redelivers unacknowledged messages, with a dead-letter queue as the terminal state. → Message Queues


Practical guidance

Cap the total time, not just the attempt count. A user waiting 30 seconds through five retries has already left. Bound the overall deadline.

Log retries as a metric. A rising retry rate is a leading indicator of trouble — often visible before error rates or latency move. It’s an excellent alert.

Make the retryable/non-retryable distinction explicit in your error types, rather than re-deriving it from status codes at each call site.

Don’t retry inside a database transaction. You’ll hold locks across the backoff sleep. → Transactions

Consider hedged requests for latency rather than failures: if no response by the p95, send a duplicate to another replica and take the first answer. Google reported large p99.9 improvements for a few percent extra load. It’s a nice answer to “how would you reduce tail latency?” → Performance Metrics


⚖️ Trade-offs

Decision Gain Cost
Retries Recover from transient failures invisibly Amplify load exactly when the system is struggling
Exponential backoff Gives the dependency room to recover Later attempts are slow for the user
Jitter Prevents synchronized retry waves None worth mentioning — always add it
Retry budget Bounds aggregate retry load Some recoverable requests fail
Retry at one layer only No multiplication That layer must handle it well
Aggressive timeout Bounded resource use; fail fast Kills slow-but-successful requests
Hedged requests Much better tail latency ~5% extra load; requires idempotency

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

1. Cause a retry storm. Service A calls service B with 3 retries, no backoff. Make B fail 50% of requests. Graph B’s incoming request rate. Watch it multiply and watch B get worse. Then add exponential backoff with jitter and watch the curve flatten.

2. Prove jitter matters. Simulate 1,000 clients all failing at t=0, retrying with pure exponential backoff. Plot requests-per-second over time — you’ll see sharp spikes at 1 s, 3 s, 7 s. Add full jitter and re-plot. The spikes become a smooth curve. This visual makes the point better than any explanation.

import random
# Without jitter: every client retries at exactly the same moments
# With jitter:    delay = random.uniform(0, min(cap, base * 2**attempt))

3. Measure amplification. Instrument a 3-layer call chain with 3 retries each. Count requests arriving at the bottom service for one client request during a failure. Confirm it’s 27.

4. Implement a retry budget. Add a rolling counter capping retries at 10% of successes. Trigger a dependency failure and watch retries get rejected once the budget is exhausted — and watch the dependency’s load stay flat instead of climbing.


Check yourself

1. Why isn't exponential backoff enough on its own? Because it doesn't break synchronization. If 1,000 clients fail at the same instant — which is exactly what happens when a dependency goes down — they all wait exactly 1 second and all retry at exactly t=1, then all wait 2 seconds and all retry at t=3. You get repeated thundering herds in perfect lockstep. The backoff reduced the *average* rate but did nothing about the *peaks*, and peaks are what overwhelm a recovering service. **Jitter** randomizes each client's delay so retries spread evenly across the window. AWS's simulations found full jitter — `random.uniform(0, min(cap, base * 2**n))` — minimized both total work and completion time.
2. When is it unsafe to retry a request that timed out? Whenever the operation is not idempotent. A timeout is ambiguous: the request may never have arrived, may still be executing, or may have completed with only the response lost. For a non-idempotent operation — charging a card, placing an order, incrementing a counter — retrying risks doing it twice, and you have no way to tell which case you're in. The fix is to make the operation idempotent with a client-generated idempotency key, so the retry returns the original result instead of repeating the effect. Alternatively, poll a status endpoint to discover what happened before deciding.
3. What is retry amplification and how do you prevent it? Retries at multiple layers multiply. If the client retries 3 times, the gateway retries 3 times, and service A retries 3 times, one user request becomes 27 requests at service B — and with five layers it's 243. During a partial outage, this converts a manageable degradation into a total collapse, because the failing service receives orders of magnitude more load precisely when it can least handle it. Prevention: **retry at one layer only** — usually the outermost or innermost, chosen deliberately and documented — and add a **retry budget** capping aggregate retries as a fraction of traffic. Watch particularly for applications retrying *and* a service mesh retrying, which happens by accident.
4. What's a retry budget and why is it better than a per-request retry limit? A retry budget caps retries as a *fraction of total traffic* over a rolling window — typically 10% of successful requests — rather than limiting each request to N attempts. The difference matters because a per-request limit bounds one client's behaviour but says nothing about aggregate load: if the failure rate goes to 50%, every request retries up to its limit and the dependency's load multiplies. A budget bounds what the *dependency actually experiences*: once retries exceed the budget, further retries are rejected immediately, so load stays flat and the struggling service gets room to recover. It's the mechanism that actually prevents retry storms.
5. Why must timeouts decrease as requests go deeper into a call chain? So that inner components stop working on requests nobody is waiting for. If the client times out at 10 seconds but the database's timeout is 30, then at t=10 the client gives up and the user sees an error — while the database keeps executing the query for another 20 seconds, holding locks, consuming a connection, and producing a result that will be discarded. Under load, that wasted work compounds and reduces effective capacity significantly. Decreasing timeouts inward (client > gateway > service > database) ensures the innermost component abandons first, so each layer can report a coherent error. The refinement is **deadline propagation** — pass the remaining budget with each call, as gRPC does, so every hop knows exactly how much time is actually left.

Further reading