Retry storms and metastable failures: an interactive simulator
Retries are meant to hide brief failures, but under overload they multiply load exactly when the server can least take it. A short capacity drop makes requests time out, clients retry them, the extra load keeps the server overloaded after the original cause is gone, and the system stays down on its own. This is a metastable failure: a stable bad state that the trigger started but no longer explains.
The playground cuts a server’s capacity from 1,000 to 300 requests a second for five seconds while clients send 800 a second. Compare four retry policies, then let the server drop requests whose clients have given up.
Clients send 800 requests a second and give up after 1 s; the server handles 1,000 a second, except from 20 to 25 s, when a failing dependency cuts it to 300. The server works through its queue in order, including requests whose clients have already given up. Even without retries, that dead work keeps useful throughput near zero until 34 s, nine seconds after the blip ended. With eager retries, every timed-out request comes back up to three times: clients send 3,200 a second, the queue holds 86,890 requests, and the server spends all its capacity on requests nobody is waiting for. It never recovers, though the blip lasted five seconds. Backoff spreads the retries out but sends just as many. A 10% retry budget keeps the extra load small enough to drain, recovering at 55 s.
- Recovered
- at 34 s
- Requests failed
- 9,290
- Recovered
- never
- Requests failed
- 52,050
- Recovered
- never
- Requests failed
- 46,810
- Recovered
- at 55 s
- Requests failed
- 26,322
How five seconds becomes forever
During the blip the queue grows faster than the server drains it, and requests start timing out. A server working through its queue in order still spends capacity on every one of those requests, though no client is waiting for the answer. Even with no retries, useful throughput stays near zero for nine seconds after the blip while the dead work drains. With clients retrying each timeout three times at once, they send 3,200 requests a second, four times the normal load, and the queue never drains: the server stays busy answering requests that have already been abandoned, every answer arrives too late, and every timeout breeds more retries. Backoff with jitter does not help here; it spreads retries out but sends just as many.
Breaking the loop
- Drop work nobody is waiting for. Propagate deadlines with requests and have the server reject anything already past its deadline before doing real work. In the playground this alone makes every policy recover the moment capacity returns. See deadline propagation.
- Bound retries globally. A retry budget allows retries worth at most a fixed share of normal traffic, say 10%, so retries can never multiply load. Here it turns “never recovers” into recovery at 55 s even without server help.
- Retry at one layer and stop retrying when a circuit breaker sees most calls failing.
- Shed load early with bounded queues, so overload turns into fast rejections instead of a growing backlog. See load shedding.
Backoff and jitter still matter: they stop synchronized retries from arriving in waves. But they reduce the rate of retries, not their number; only a budget or a breaker limits the total.
A retry budget and a deadline check in code
import time
class RetryBudget:
"""Allow retries worth at most 10% of the requests sent recently."""
def __init__(self, ratio=0.1, cap=100):
self.ratio, self.cap, self.tokens = ratio, cap, 0.0
def on_request(self):
self.tokens = min(self.cap, self.tokens + self.ratio)
def try_retry(self) -> bool:
if self.tokens >= 1:
self.tokens -= 1
return True
return False # out of budget: fail fast instead of piling on
# server side: never work on a request its caller has given up on
def handle(request):
if time.monotonic() > request.deadline:
return reject(request, "deadline exceeded") # costs microseconds, not a full request
return process(request)In a system design interview
When you add retries, say how they are bounded: “Clients retry idempotent calls at most twice with jittered backoff, within a per-client retry budget of 10% of requests. Every request carries its deadline, and services reject expired requests at the door. Queues are bounded and overflow returns 503 at once, so a brief slowdown cannot turn into a backlog we spend minutes clearing.” Expect the follow-ups: how you would detect a metastable state, and how to bring a service back up when clients are already hammering it.
Read the guide to resilience or load shedding and backpressure, see exponential backoff, or watch a strong candidate design a payment system.
Questions people ask
What is a retry storm?
A surge of load caused by clients retrying failed requests. When a service slows down, requests time out, clients retry them, and the extra requests slow the service further, so failures multiply instead of clearing.
What is a metastable failure?
A failure state that sustains itself after its trigger is gone. A brief capacity drop starts timeouts and retries, and the resulting overload keeps causing timeouts and retries even when capacity is back, so the system stays down until load is cut by hand.
Why do servers keep failing after the original problem is fixed?
Because their queues are full of requests whose clients have already given up, and processing them produces answers nobody receives. Meanwhile clients keep retrying, so every new request also waits too long and times out. Useful throughput stays near zero although the server is fully busy.
Does exponential backoff prevent retry storms?
Not by itself. Backoff with jitter spreads retries over time and prevents synchronized waves, but each failed request is still retried the same number of times. Limiting the total, with a retry budget or a circuit breaker, is what stops retries from multiplying load.
What is a retry budget?
A limit on retries relative to normal traffic, for example retries may add at most 10% to the requests a client sends. When failures exceed the budget, extra requests fail fast instead of being retried, so retries can never more than slightly increase load on a struggling service.
How should servers handle requests whose clients have timed out?
Reject them before doing real work. Clients send their deadline with each request, and the server checks it when the request reaches the front of the queue. Dropping abandoned work lets the server spend its capacity on requests that can still succeed.
How do you recover from a retry storm?
Reduce load below capacity long enough for queues to drain: shed load at the edge, open circuit breakers, disable retries, or scale out temporarily. Then bring traffic back gradually. Bounded queues, deadlines and retry budgets prevent the situation from arising in the first place.