Retry Storm
What happens when a slow downstream dependency meets an uncontrolled retry policy — and how a mechanism meant to help makes an outage worse.
What Is a Retry Storm?
A retry storm occurs when retries — meant to recover from transient failures — add enough extra traffic to an already-struggling dependency that they make it worse instead of better.
Retries exist for a good reason. Networks drop packets. Services have momentary blips. A single failed request, retried once, quietly recovers and the caller never notices. This is healthy retry behavior — rare, cheap, mostly invisible. The general mechanics live in Retry & Backoff; this page is about what happens when that pattern is applied without enough restraint.
The problem starts when a dependency doesn't just blip — it degrades under load. Latency climbs. Requests start timing out. Callers retry those timeouts. But the retries don't land on a healthy service recovering from a hiccup — they land on a service that is already struggling, adding more load at the worst possible moment.
That's the difference between healthy retries and retry amplification: healthy retries recover from rare, independent failures. Retry amplification happens when the failures are caused by load, and the retries add more load — closing a feedback loop that feeds on itself.
Why Does It Happen?
A retry storm needs three ingredients: a dependency that degrades gradually under load, a retry policy that reacts without regard for that degradation, and enough concurrent callers for the extra load to matter.
Gradual Degradation
Dependencies rarely fail instantly. They slow down first — a database connection pool fills, a downstream call gets congested, CPU creeps up. Latency climbs from 50ms to 100ms to 1s to 3s before anything is technically 'down'.
Timeouts Trigger Retries
Once latency crosses the caller's timeout, requests start failing — not because the dependency is broken, but because the caller gave up waiting. A retry policy sees a failure and reacts the only way it knows how: try again.
Retries Add Load Instantly
Without backoff, a retry happens immediately after a timeout. Without a retry limit, it can happen indefinitely. Every retry is a brand new request landing on a dependency that hasn't gotten any healthier since the last one failed.
The critical insight is that this is a feedback loop problem, not a retry problem. Retrying is the right instinct for a transient failure. The danger is a retry policy that can't tell the difference between "this failed because of bad luck" and "this failed because the dependency is drowning" — and reacts to both the same way: immediately, and without limit.
Where does "280 calls" come from? The numbers are illustrative. Suppose a policy allows at most 3 attempts per request (1 original + 2 retries). All 100 requests time out on the first attempt, so they send 100 first retries; 80 of those time out again and send 80 second retries. Total calls = 100 + 100 + 80 = 280, an amplification of 280 ÷ 100 = 2.8 attempts per request. The worst case for this policy is 3 × 100 = 300. If two layers each retry up to 3 times (say the gateway and Service A), a single user request can become up to 3 × 3 = 9 calls on the dependency, which is why retries must be budgeted across layers, not per layer.
Watch It Happen
Press play to watch a retry storm unfold in a simulated system — from a healthy call chain, through gradual degradation, timeouts, retry amplification, mitigation, and full recovery.
Retry Storm
⤢ Expand for synchronized narration timeline
Why Is It Dangerous?
A retry storm can turn a minor slowdown into a full outage — and it gets worse exactly when engineers most need the system to stabilize.
Because the loop is self-reinforcing, a retry storm often keeps getting worse until something changes the traffic pattern. Every retry consumes threads, connections, and CPU on both sides of the call — the caller blocks waiting for a response it may never use, and the callee spends resources on requests that have often already been abandoned or retried elsewhere.
Response times climb from milliseconds to seconds as the dependency queues an ever-growing pile of original requests and retries.
Thread pools and connection pools exhaust, causing further timeouts and 503 errors returned to end users.
Every in-flight retry holds a thread or connection open on the caller, starving unrelated requests that have nothing to do with the failing dependency.
If Service A blocks on Service B, callers of Service A start timing out too — the storm propagates upstream through the whole call chain.
The same causes recur in many retry storm postmortems:
- No retry limit — a request can be retried indefinitely.
- Aggressive retry policy — retrying on almost any failure, including ones that will not resolve by retrying.
- Very short (or zero) delay between retries.
- Many clients retrying simultaneously, in near-perfect sync.
- No exponential backoff — every retry adds the same load as the last.
- No jitter — synchronized clients stay synchronized, wave after wave.
- Retrying non-idempotent operations, risking duplicate side effects.
- Retrying into a dependency that is already visibly overloaded, instead of backing off.
When a caller blocks on a dependency this way, its own callers start timing out too — the pressure spreads upstream through the whole call chain instead of staying isolated to one slow dependency. See Cascading Failure for how that propagation happens in detail.
How to spot it
The tell-tale sign is that the dependency's load stops tracking user demand. Watch these signals:
- Retry rate — client-side retries per second, or the share of attempts that are retries, rising from near zero.
- Attempts per request (the amplification ratio) = total attempts ÷ original requests. Healthy is close to 1.0; a sustained value of 2 or more means the retry policy is doing a large share of the work.
- Dependency request rate versus edge request rate — callee RPS climbs while user-facing RPS stays flat, and callee saturation (CPU, pool, queue depth) follows.
- Timeouts and latency — timeouts at the caller while the callee's own error log shows many requests it completed after the caller had given up.
- T+0 s: the dependency slows past the 1 s timeout. Client timeouts jump from about 0% to nearly all first attempts.
- T+1 s: retries begin: 100 first retries per second, then 80 second retries. Attempts per request moves from 1.0 to (100 + 100 + 80) ÷ 100 = 2.8.
- T+1–2 s: the dependency receives 280 calls/s against about 150 calls/s of capacity (187%) while user traffic is unchanged at 100 req/s, so it slows further and the next wave fails even more often.
- T+several seconds: requests that exhaust their attempts return errors and the dependency stays saturated until retries are limited or its load is cut.
How Do Engineers Fix It?
Five techniques address different parts of the loop. Production systems almost always combine several of them. Timeouts bound a single attempt; retry limits or deadlines bound how much total time and traffic a caller spends across all attempts.
Timeouts
Every downstream call should have an appropriate timeout, so a caller never holds resources indefinitely waiting for a response.
Set a timeout that reflects the call's realistic latency budget. When it's exceeded, the caller gives up and frees its thread/connection instead of blocking forever.
A timeout alone does not solve retry storms — it only bounds the wait. The retry policy that reacts to the timeout is what determines whether the system recovers or spirals.
Exponential Backoff
Instead of retrying immediately, each retry waits longer than the last — 1s, then 2s, then 4s, then 8s — giving the dependency time to recover.
Track the retry attempt number and compute delay = base * 2^attempt (capped at a maximum). Wait that long before issuing the next retry.
Slower recovery for the individual request — a client that needs 3 retries now waits several seconds instead of retrying instantly. That trade is usually worth it to protect the dependency.
Jitter
Randomize each retry delay so that many clients failing at the same moment don't retry in synchronized waves.
Instead of a fixed delay per attempt, use delay = random(0, base * 2^attempt), or add a random offset to the computed backoff delay.
Individual retry timing becomes less predictable, which can complicate debugging — but it's essential once more than a handful of clients can fail together.
Retry Limits
Bound the number of attempts (or total retry duration) for any single request, instead of retrying indefinitely.
Track attempt count per request. After N attempts (often a small number such as 3) or a maximum elapsed time, stop retrying and return a failure to the caller.
Some requests fail sooner than they might have with infinite retries — but the amount of extra load any single request can generate is now bounded.
Circuit Breaker
When a dependency is clearly unhealthy, stop calling it entirely for a cool-down period, and fail fast instead of retrying into it.
Track recent error rates per dependency. Once a threshold is crossed, the breaker opens: calls fail immediately without reaching the dependency. After a cool-down, it goes half-open, allowing a few test requests through. If they succeed, the breaker closes; if not, it stays open.
Requests fail immediately while the breaker is open, even ones that might have succeeded — but the dependency is protected from retry pressure and gets real room to recover.
| Technique | Purpose | Main Benefit | Trade-off |
|---|---|---|---|
| Exponential Backoff | Increase retry delay | Reduces pressure on the dependency | Slower recovery per request |
| Jitter | Randomize retries | Prevents synchronized retry waves | Less predictable timing |
| Retry Limit | Bound retries | Prevents unlimited amplification | Some requests fail sooner |
| Circuit Breaker | Stop unhealthy calls | Protects the dependency | Temporary fail-fast failures |
| Timeout | Bound waiting | Frees resources | May terminate slow-but-valid operations |
Retries Aren't the Enemy
The lesson is not "retries are bad." It's "uncontrolled retries can amplify an outage" — and not every operation should be retried the same way.
A read — fetching inventory, looking up a user — is usually safe to retry when the failure looks transient, like a timeout, a connection reset, or a 503. A validation 400 or an auth failure usually is not. But a non-idempotent write — charging a card, sending a notification, incrementing a counter — is a different story. If the original request actually succeeded but the response was lost, a naive retry can perform the same side effect twice.
The standard fix is an idempotency key: the caller generates a unique key per logical operation and sends it with every attempt, including retries. The receiving service uses that key to recognize "I've already done this" and returns the original result instead of repeating the side effect.
Engineering Takeaway
- A retry storm is a feedback loop problem, not a retry problem. Retrying is the right instinct for a transient failure.
- The danger appears when a dependency degrades under load and the retry policy can't tell the difference between bad luck and an overloaded dependency.
- Exponential backoff and jitter spread retry traffic out in time instead of piling it onto a struggling dependency all at once.
- Retry limits bound how much extra load any single request can generate — never unlimited.
- Circuit breakers go furthest: they remove load from an unhealthy dependency entirely, giving it real room to recover.
- Not every operation should be retried the same way — retrying a non-idempotent write without an idempotency key can create duplicate side effects.
- Think about what your retry policy does when the downstream service is already struggling — not just when it's healthy. That's the difference between a brief blip and a full outage.