Designing for partial failure
Open in the interactive app: practice, quiz, mock interview →In one minute
- In a large fleet, something is always failing. The goal is not to prevent failure but to contain it, so one bad dependency degrades a feature instead of taking down the product.
- Know the failure model you design for: crash-stop, crash-recovery, omission, timing, or Byzantine. Most business systems assume crash-recovery with unreliable timing.
- The core toolkit is timeouts, retries with backoff and jitter, idempotency, circuit breakers, bulkheads, load shedding and redundancy.
- Retries are both the main recovery tool and the main amplifier of outages. Bound them.
- Prefer static stability: a system that keeps working with what it already has when a dependency disappears.
How it works
Failure models
A crash-stop node halts and never returns. A crash-recovery node halts and later restarts, possibly having lost in-memory state, so anything promised must be on disk first. Omission failures drop messages. Timing failures respond, but too late to matter. Byzantine failures send wrong or conflicting data; outside blockchains and safety-critical systems, we usually defend against these only with checksums and validation.
The most damaging real failures are grey: a node that is slow, not dead. It passes health checks, keeps taking traffic, and drags down every caller's p99.
Detect: timeouts and health checks
Every remote call needs a timeout. Set it from the dependency's measured latency, not from a round default like 30 seconds. AWS picks the false-timeout rate it can live with, such as 0.1%, and sets the timeout at that percentile of downstream latency, here p99.9 (AWS Builders' Library). Deadlines should propagate: if the user request has 800 ms left, downstream calls get less than that, not their own full budget. Health checks should be shallow for liveness (is the process alive) and deeper for readiness (can it serve), and deep checks should never fail a whole fleet at once because a shared dependency blinked.
Recover: retries done safely
Retry only operations that are idempotent or carry an idempotency key. Use exponential backoff with full jitter, so delay is a random value between zero and min(cap, base * 2^attempt); jitter spreads out clients that failed together. Cap attempts at 2 or 3. In a stack of 4 layers that each retry 3 times, one user request can produce 3^4 = 81 calls to the bottom service; AWS's own example of 5 layers gives 243x. Retry at one layer only, and use a retry budget. Google allows at most 3 attempts per request and stops a client retrying once retries pass 10% of its requests, which cuts worst-case growth from almost 3x to about 1.1x (Google SRE book). AWS SDKs build in the same idea: standard mode allows 3 attempts in total with full-jitter backoff, and a 500-token retry quota that each retry draws from stops a client retrying during a sustained outage (those numbers are from AWS's 2026 retry update, opt-in until it becomes the default) (AWS SDK docs).
Contain: breakers, bulkheads, shedding
A circuit breaker tracks recent failures to a dependency. Past a threshold it opens and fails calls immediately, then lets a few probe requests through after a cool-down. This gives the dependency room to recover and frees your threads. Bulkheads give each dependency its own connection pool or thread pool, so a stuck dependency exhausts only its own slots. Load shedding rejects work early, ideally by priority, when queue depth or latency crosses a limit, because serving 80% of requests well beats serving 100% badly. Be wary of clever fallbacks that switch to a different system under stress: AWS avoids them because they run rarely, hide bugs for years, and can push the extra load onto whatever is still standing (AWS Builders' Library).
Survive: redundancy and static stability
Run N+1 or N+2 capacity across availability zones so one zone can vanish without overload. Pre-provision rather than relying on scaling up during the incident. Cache config and credentials so the data plane keeps working when the control plane is down.
Down the stack
Layer 1The contract
Every remote call ends in one of three states: success, a definite failure, or unknown. The protocol tells you which. In gRPC, DEADLINE_EXCEEDED means unknown: the docs warn it can come back even when the operation succeeded. UNAVAILABLE is the code for a transient condition worth retrying with backoff, but the docs still say retrying non-idempotent calls on it is not always safe (gRPC status codes). In HTTP, RFC 9110 defines GET, HEAD, PUT, DELETE, OPTIONS and TRACE as idempotent and POST as not; a 503 or 429 with Retry-After is the server telling you when to come back. Payment APIs such as Stripe accept an Idempotency-Key header so a retried POST returns the stored first result (Stripe docs).
A well-behaved client therefore promises its own caller three things you can test: total latency never exceeds the deadline it was given, attempts never exceed N, and retry traffic never exceeds a fixed fraction of normal traffic.
Layer 2The mechanism
Deadlines travel on the wire. gRPC sends the remaining budget in the grpc-timeout header (for example 250m for 250 ms) (gRPC over HTTP/2), and in Go the server side surfaces it as a context.Context whose cancellation propagates to every downstream call made with that context.
Retry budgets are small counters. gRPC's retry throttling keeps a token bucket per server: each failure subtracts 1 token, each success adds tokenRatio (for example 0.1), and retries are allowed only while the bucket is above half of maxTokens; maxAttempts above 5 is treated as 5 (gRFC A6). Envoy expresses the same idea as a retry budget, by default 20% of active requests with a floor of 3 concurrent retries, alongside num_retries and a per_try_timeout (Envoy docs).
Breakers and bulkheads are mostly counting. A Resilience4j-style breaker keeps a sliding window, either the last N call outcomes in a ring buffer or per-second buckets, and opens when the failure rate crosses a threshold. Envoy's "circuit breakers" are really bulkheads: hard caps on connections, pending requests and active requests per upstream cluster, with overflow rejected immediately. Its outlier detection ejects a single host after 5 consecutive 5xx responses by default, for a 30 s base ejection time that grows on repeat offences, and by default ejects at most 10% of the cluster at once (Envoy docs).
Layer 3The machine
A hung dependency consumes concrete resources. By Little's law, in-flight work equals arrival rate times latency: 2,000 RPS held for 10 s is 20,000 concurrent requests. With thread-per-request each one pins a thread, typically 1 MB of reserved stack on a 64-bit JVM, plus a socket, a file descriptor and kernel send and receive buffers. Goroutines start with a stack of a few KB, so Go survives more stuck calls, but it still runs out of file descriptors and connection slots.
The kernel's own failure detection is too slow to rely on. A connect() to a host that silently drops SYNs keeps retransmitting SYN and, with the Linux default tcp_syn_retries = 6, fails with ETIMEDOUT only after about 131 s. Kernels since mid-2023 (Linux 6.5) send the first 4 retries 1 s apart (tcp_syn_linear_timeouts = 4) and only then back off exponentially; older kernels doubled from the start and gave up after about 127 s (kernel docs). A closed port on a live host replies with RST and fails in one round trip as ECONNREFUSED. That asymmetry is why "down" is cheap and "unreachable" is expensive, and why clients must set explicit connect and request timeouts.
Timers themselves are cheap if built well. Event loops pass the nearest deadline as the timeout to epoll_wait, and runtimes use timer wheels or heaps so that tracking a hundred thousand pending timeouts costs O(1) or O(log n) per operation. Timeouts should be measured with a monotonic clock so an NTP step cannot fire them early or late.
Why it's built this way
- A remote node's silence has no definite meaning, so every call needs a client-side deadline and a plan for the unknown outcome.
- Unknown outcomes force retries, and retries are only safe with idempotency keys or idempotent operations.
- Retries add load exactly when a dependency is weakest, so they need jitter to spread out and budgets to cap amplification.
- Stuck calls hold threads, sockets and memory, which are finite per host, so bulkheads and breakers cap how much of that capacity one dependency can take.
Diagram
stateDiagram-v2
[*] --> Closed
Closed --> Closed: success, or failures under threshold
Closed --> Open: failure rate over 50% in 10 s
Open --> Open: calls fail fast
Open --> HalfOpen: after 30 s cool-down
HalfOpen --> Closed: probe requests succeed
HalfOpen --> Open: a probe failsWorked example
A checkout service handles 2,000 requests per second and calls a recommendations service (p99 120 ms) and a payment service (p99 300 ms). Each instance has 200 worker threads.
Recommendations starts hanging. With a 10 s timeout and a shared pool, each stuck request holds a thread for 10 s. At 2,000 RPS, all threads are stuck within a fraction of a second and checkout, including payment, goes down. A non-critical feature has taken out revenue.
Now apply the toolkit. Recommendations gets a 250 ms timeout, its own 40-thread bulkhead, a circuit breaker that opens at 50% failures, and a fallback of an empty list. When it hangs, at most 40 threads are involved, the breaker opens within seconds, and checkout continues at full rate with no recommendations shown. Payment keeps a 1 s timeout, 1 retry with jitter, and an idempotency key so a retried charge is never applied twice.
In the wild
- AWS, 2021: retries without backoff congest the internal network. On 7 December an automated scaling action in us-east-1 set off a connection surge from many internal clients. A latent bug kept those clients from backing off, so their retries held the devices between AWS's internal and main networks in congestion for hours; Route 53 APIs were impaired from 7:30 AM to 2:30 PM PST and the operators' real-time monitoring data was unavailable for much of it. AWS disabled the scaling activity and committed to a client backoff fix (AWS).
- Google Cloud, 2025: a crash loop becomes a thundering herd. On 12 June a quota-policy row with blank fields replicated worldwide within seconds and hit a code path in Service Control with no error handling and no feature flag, crash-looping it in every region. When tasks restarted in us-central1 they overloaded the Spanner table behind them, because they lacked randomized exponential backoff; that region took about 2h40m to recover. Fixes include failing open, feature flags for critical changes and an audit for jittered backoff (Google Cloud).
- Slack, 2022: retries lock in a cache-miss cascade. A Consul agent rollout replaced memcached nodes with empty ones, and each client boot then ran a query across every database shard. Timeouts stopped the cache refilling and client retries kept the load up. Responders throttled client boots and raised the limit in small steps; their first, larger step pushed the database back over the edge (Slack).
Trade-offs
| Technique | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Retry with backoff | Absorbs transient errors | Amplifies load; needs idempotency | Short, rare failures |
| Circuit breaker | Fails fast, protects dependency | Tuning; can open on noise | Remote calls that can hang |
| Bulkhead | Limits blast radius | Lower utilisation per pool | Mixed critical and optional deps |
| Load shedding | Keeps latency bounded | Some users get errors | Overload is possible |
| Fallback | Feature degrades gracefully | Rarely tested path | Optional data |
Pitfalls in production
- Retries at every layer multiplying load during an incident. Fix: retry at one layer and enforce a retry budget.
- Timeouts longer than the caller's own timeout, so work continues for requests nobody is waiting for. Fix: propagate deadlines and cancel downstream work.
- Fallbacks that call a different, untested system and fail in a new way. Fix: prefer static fallbacks and exercise them in game days.
- Health checks that depend on a shared database, so a database blip marks every node unhealthy. Fix: keep liveness shallow and fail open when all nodes look sick.
- Synchronised recovery: every client reconnects at the same second. Fix: jitter reconnects and warm caches gradually.
Leadership lens
- Ask for each dependency: timeout, retry policy, breaker, fallback, and whether it is critical to the request. Put this in the design doc as a table.
- Decide where retries live. One owner per call path avoids exponential amplification.
- Fund chaos and game days. Containment code is only real once it has been triggered on purpose.
Check yourself
Why is a slow dependency often worse than a dead one?
Show answer
A dead one fails fast and trips breakers; a slow one holds threads and connections until timeouts fire, exhausting caller resources and spreading latency upstream.
Why add jitter to exponential backoff?
Show answer
Clients that failed together would otherwise retry together in waves. Randomising the delay spreads retries out so the recovering service sees smooth load.
What does a circuit breaker's half-open state protect against?
Show answer
Sending full traffic to a dependency that may still be broken. It lets a few probes test recovery before closing again.
Sources and further study
Primary
- gRFC A6: gRPC client retries: retry policy, hedging and the token-bucket retry throttle, as specified.
- RFC 9110: HTTP Semantics, idempotent methods: which methods a client may safely repeat.
- Linux ip-sysctl documentation: the SYN, retransmission and keepalive timers that decide how slowly the kernel notices a dead peer.
Systems docs
- AWS SDKs: retry behavior: standard and adaptive modes, the backoff formula and the retry quota numbers.
- Envoy: outlier detection: host ejection rules and the safeguards that stop a proxy ejecting a whole cluster.
- gRPC: deadlines: how deadlines are set, propagated and cancelled across services.
Books
- Designing Data-Intensive Applications (1st ed.), Martin Kleppmann, Ch 8 "The Trouble with Distributed Systems": partial failures, unreliable networks and why timeouts are guesses.
- Release It! (2nd ed.), Michael T. Nygard, Ch 4 "Stability Antipatterns" and Ch 5 "Stability Patterns": chain reactions, blocked threads, timeouts, breakers and bulkheads.
- Site Reliability Engineering, Beyer et al. (eds.), Ch 21 "Handling Overload": per-customer limits, client-side throttling, criticality and retry budgets.
Other curricula
- donnemartin/system-design-primer: covers failure through fail-over (active-passive with heartbeats) and back pressure with exponential backoff; it does not cover breakers, retry budgets or deadlines.
- roadmap.sh: System Design: lists Retry, Retry Storm, Circuit Breaker and Bulkhead as separate one-paragraph reliability-pattern nodes.
Watch
- Distributed Systems 2.4: Fault tolerance (Martin Kleppmann): failure models, failure detectors and what fault tolerance means.
- L5: The many types of fail (Distributed Systems Course): a tour of crash, omission, timing and Byzantine failures.
Free resources
- Timeouts, retries, and backoff with jitter (AWS Builders' Library): how AWS sets timeouts and bounds retries.
- Addressing Cascading Failures (Google SRE book): how overload spreads and the controls that stop it.
- Circuit Breaker pattern (Azure Architecture Center): states, tuning and when not to use it.
- Static stability using Availability Zones (AWS Builders' Library): designing systems that keep working when dependencies fail.
- Making retries safe with idempotent APIs (AWS Builders' Library): idempotency tokens in practice.