Learn how circuit breakers, bulkheads, load shedding, retry budgets, hedged requests and chaos experiments keep one failing dependency from taking down a whole system.
What you'll learn: By the end of this lesson you will be able to explain how failures cascade through a service graph, apply circuit breakers, bulkheads, back-pressure and load shedding at the right places, set retry budgets and timeout budgets that do not amplify outages, and plan a game day to test your assumptions.
Think of a ship with watertight compartments. A hole in one compartment floods that compartment, not the whole ship. The classic lesson from ship disasters is that walls which do not reach high enough let water spill from one section into the next. Software has the same shape. A slow database should flood one feature, not the entire product.
Distributed systems fail in a particular way: not with a clean crash, but with slowness. A dependency that answers in 30 seconds instead of 30 milliseconds is often more dangerous than one that is simply down, because callers keep waiting, threads pile up, queues fill, and the problem spreads upstream. This is a cascading failure.
This lesson is a toolbox for containing damage. It builds on reliability basics, where we met timeouts, retries and idempotency, and on observability and SLOs, because you cannot tune any of these patterns blind. For an AI-flavoured companion, see AI system resilience.
The core idea
Resilience is not about preventing failure. It is about limiting the blast radius and degrading gracefully. Every pattern below answers one of three questions:
Detect: How quickly do we notice a dependency is unhealthy?
Contain: How do we stop the failure from consuming shared resources (threads, connections, memory, CPU)?
Recover: How do we avoid making recovery harder, for example by stampeding a service that just came back?
Every service has finite capacity: threads, connections, queue slots. A failure spreads when one slow dependency uses up a shared pool that unrelated work also needs. Most of the patterns work by separating pools or refusing work early.
How a cascade happens
Suppose the recommendation database slows down. Recommendations are optional, but the order service calls them on every checkout. Each call now holds a thread for 30 seconds. Within a minute the order service has no free threads, so checkout stops even though payments and orders are healthy. The web tier's threads fill next. A non-critical feature has taken the whole site down.
Retries make it worse. If every caller retries up to three times, the struggling database can receive roughly four times its normal traffic exactly when it can least cope. This retry storm is one of the most common ways small incidents become large ones.
Timeouts and timeout budgets
Every network call needs a timeout. With none, a call can wait forever, and "forever" times the request rate equals an exhausted thread pool. A timeout is a decision about how long the answer is still useful. Set it from data: look at the callee's latency distribution (see requirements for p50, p95 and p99); a common starting point is a little above the normal p99, not an arbitrary 30 seconds.
In a chain of calls the timeouts must be consistent. This is the timeout budget, also called deadline propagation. The edge sets a deadline, passes the remaining time downstream with each call, and each service uses the remaining budget minus a safety margin as its own timeout. If the budget is nearly spent, the service fails fast instead of starting work nobody will wait for.
sequenceDiagram
participant E as Edge
participant O as Order service
participant P as Payment service
participant B as Bank API
E->>O: request with 2000 ms deadline
Note over E,O: edge and network spend 50 ms
Note over O: spends 100 ms of its own work
O->>P: remaining 1850 ms, minus margin gives about 1800 ms
Note over P: spends 100 ms
P->>B: remaining about 1700 ms
Note over P: a 5000 ms bank timeout would outlive the user
B-->>P: reply or deadline exceeded
For illustration, assume a 2,000 ms budget. If the payment service's own timeout to the bank is 5,000 ms, the user gave up long before and the payment service is doing useless work. The rule: an inner timeout must always be shorter than the outer timeout that contains it. Propagating the deadline enforces that automatically instead of relying on every team to configure it correctly.
Retries with budgets
Retries help with brief, random failures such as a dropped packet or a restarting instance. They hurt when the failure is overload, because they add load.
flowchart TD
F["Call failed"] --> I{"Operation idempotent or keyed?"}
I -->|no| X1["Do not retry, surface the error"]
I -->|yes| E{"Error retryable? timeout, 503, reset"}
E -->|no| X2["Do not retry, for example 400"]
E -->|yes| B{"Retry budget and attempts left?"}
B -->|no| X3["Stop and fail fast"]
B -->|yes| R["Wait with backoff and jitter, retry once"]
The flowchart reads as a series of gates; a retry happens only if every gate passes. The rules behind it:
Retry at one layer only. If the client, the gateway and the service each make up to three attempts, one user action can become 27 calls. Retry closest to the failure and let the others fail through.
Use exponential backoff with jitter so retries from many clients do not arrive in synchronised waves. Honour Retry-After when present.
Use a retry budget. Allow retries to make up at most a fixed fraction of traffic, for example 10 percent of recent requests (an illustrative assumption). When the budget is spent, stop retrying. This keeps the worst case near 1.1 times normal load rather than 4 times.
Circuit breakers
A circuit breaker wraps calls to a dependency and watches their outcomes. Like the electrical device, it trips to stop damage, then tests whether it is safe to resume.
stateDiagram-v2
[*] --> Closed
Closed --> Open: failure rate over threshold
Open --> HalfOpen: wait period elapsed
HalfOpen --> Closed: trial calls succeed
HalfOpen --> Open: a trial call fails
Closed: calls pass through and failures are counted over a sliding window.
Open: the failure rate (or slow-call rate) crossed a threshold. Calls fail immediately without touching the dependency, protecting your threads and giving it room to recover.
Half-open: after a wait period, a limited number of trial calls are allowed. Success closes the breaker; any failure reopens it.
Tuning matters. Count slow calls as failures (a 20-second success is not healthy). Require a minimum number of calls in the window, or one failed request on a quiet service trips the breaker. Keep one breaker per dependency, or per endpoint, not one global breaker.
What happens when the breaker is open is a product decision, the fallback: cached data, a default value, an empty list or a clear error. Choose a fallback that is safe. A stale price may be unacceptable; "no recommendations right now" almost always is acceptable.
Bulkheads
A bulkhead isolates resources so one consumer cannot starve the others. Typical forms: separate thread or connection pools per dependency (recommendations get 10 threads; when those are stuck, checkout has its own pool); separate instances for critical and non-critical traffic; per-tenant quotas; and cell-based architecture, where the whole stack is deployed as several independent cells so a bad deploy or poisoned request takes down one twentieth of users, not everyone.
Pattern
Protects against
Cost
Alternative
Per-dependency pools
One slow dependency draining shared threads
Some idle capacity, more tuning
Async I/O with concurrency limits
Separate instances by criticality
Batch or low-priority work hurting core traffic
Extra infrastructure
Priority queues inside one service
Per-tenant quotas
Noisy neighbours
Quota management, rejected requests
Rate limiting at the edge
Cells
Bad deploys, poison requests, regional faults
Operational complexity, data placement
Blue-green and canary releases
Back-pressure and load shedding
When demand exceeds capacity, something has to give; the only choice is whether it gives deliberately or by collapsing. Back-pressure pushes the signal upstream, "slow down, I am full". Bounded queues are the simplest form: when full, the producer is blocked or rejected instead of growing memory without limit. See message queues for how queue depth becomes a health signal.
Load shedding is deliberately dropping some work to keep serving the rest. A server at 100 percent CPU that tries to serve everyone serves no one well: latency rises, clients time out, and work done for timed-out requests is wasted.
flowchart TD
R["Request arrives"] --> L{"In-flight above limit?"}
L -->|no| A["Admit and process"]
L -->|yes| P{"High priority such as checkout?"}
P -->|yes| A
P -->|no| S["Reject fast with 503 and Retry-After"]
A --> D{"Waited past its deadline in queue?"}
D -->|yes| S
D -->|no| OK["Run the request"]
Good shedding is selective (drop recommendations before checkout), early (before spending CPU), explicit (503 or 429 with Retry-After so clients back off), and based on concurrency or queue time, not CPU alone.
IMPORTANT
Unbounded queues do not remove overload, they hide it. A queue that holds ten minutes of work means every new request waits ten minutes, long after the client has gone. Bound the queue and decide what to do when it is full.
Hedged requests
Tail latency is a real problem at scale. If one backend call in a hundred is slow, a page that makes 50 calls will hit a slow one about 40 percent of the time (1 − 0.99^50, approximately). A hedged request attacks the tail: send the request to one replica, and if no reply arrives within, say, the p95 latency, send a second copy to another replica, use whichever answers first, and cancel the loser. It costs a few percent extra load, so hedge only idempotent reads, cap hedges under the same budget as retries, and never hedge when the system is overloaded.
Testing resilience: chaos and game days
All the settings above are guesses until tested. Chaos engineering means injecting controlled failures to check that the system behaves as designed: killing instances, adding latency to a dependency, blocking a network path. A good experiment states a steady-state hypothesis ("checkout success stays above 99.5 percent when the recommendation service is slow"), starts with a small blast radius and a kill switch, observes against your SLOs, fixes what it learns, then widens scope.
A game day is a scheduled exercise where the team rehearses an incident, including detection, escalation, communication and rollback, not only the technical failure. Many teams find their runbooks and alerts are the broken part.
TIP
Start in staging with realistic traffic, then move to small production experiments only when you have working alerts and a fast way to stop the experiment.
In practice: a retail checkout during a partial outage of recommendations and the payment provider
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
The situation
A mid-sized retailer runs its web shop on Kubernetes with about ten engineers on the checkout team. The checkout service calls three things in the request path: an internal recommendation service (backed by Redis and a feature store), an inventory service on PostgreSQL, and an external payment provider over HTTPS. Traffic at peak is around 300 checkouts per minute (an assumption). In the last incident the recommendation service slowed to 20 seconds per call; the checkout service's single HTTP client pool filled, checkout p99 went from about 1.5 seconds to over 20, and the success rate fell below 80 percent for 25 minutes even though inventory and payments were healthy. Two weeks later the payment provider had a partial outage with p99 latency of 8 seconds, and client retries from the mobile app and the gateway multiplied traffic. Constraints: no budget for a second payment provider this quarter, a Black Friday freeze in six weeks, and a rule that a customer is never charged twice.
Starting architecture
Options considered
Option
What it fixes
What it costs
Risk
Do nothing beyond lowering timeouts
Shortens thread hold time
One line of config
Pool still shared; retries still stack; the same cascade at a lower threshold
Containment package: per-dependency pools, breakers, deadline propagation, one retry layer with a budget, priority shedding
Optional features can no longer sink checkout; retry multiplication ends
A few weeks of work, tuning, new dashboards
Medium: wrong thresholds cause false trips
Add a second payment provider with automatic failover
High: two providers means two sets of edge cases and settlement differences
The decision
Field
Entry
Context
One optional dependency and one external dependency each caused a checkout outage; retries at three layers made both worse.
Decision
Adopt the containment package. Recommendations get their own pool of 10 connections, a 150 ms timeout and an empty-list fallback. Payments get a breaker, a deadline taken from a 5-second checkout budget, and one retry only when the request carries an idempotency key. Envoy retries are disabled for the checkout route; the checkout service owns retries. Anything above a concurrency limit sheds recommendation and wishlist calls first.
Consequences
Good: a recommendation outage becomes a missing widget; payment trouble produces a clear "payment is delayed" state instead of a hung page. Bad: more configuration to own, breaker thresholds need ongoing tuning, and a pending-payment state adds support work.
Revisit when
The provider is degraded for more than 30 minutes per quarter, or lost checkouts during provider incidents exceed an agreed revenue threshold; then fund the second provider.
Rollout and verification
flowchart LR
S1["Staging fault injection"] --> S2["Shadow mode, breakers log only"]
S2 --> S3["Canary 5 percent of checkouts"]
S3 --> S4["Full rollout, retries moved"]
S4 --> S5["Production game day"]
Start in staging by injecting latency on the recommendation service and confirming checkout success holds. Run breakers in shadow mode (log what they would have done) for a week, then canary at 5 percent behind a feature flag. Move retries last: enable the service-level retry budget before disabling gateway retries, or you briefly have none. Dashboards that prove it worked: checkout success rate and p99, breaker state per dependency, pool utilisation per dependency, shed requests by priority, and retry ratio (target under 10 percent of requests). Finish with a game day that slows recommendations for ten minutes and a staged payment-provider brownout, with a hypothesis of "checkout success stays above 99 percent".
The 2 a.m. failure
The page reads "Payment breaker open for 5 minutes, checkout success 91 percent". The provider's API is timing out for roughly one request in three. The dangerous part is not the timeout; it is that a timed-out charge may still have succeeded. The checkout service must not retry blindly or tell the customer it failed.
sequenceDiagram
participant C as Checkout service
participant B as Breaker
participant P as Payment provider
participant J as Reconcile job
C->>B: charge with idempotency key K
B->>P: charge K
P--xB: timeout, outcome unknown
B-->>C: unknown result
C->>C: order state pending payment
J->>P: look up payment by key K
P-->>J: succeeded
J->>C: mark order paid and confirm
First three checks: the breaker and pool dashboard to see which dependency is tripped; the provider's status page plus a synthetic probe latency to confirm the fault is external; and the retry-ratio panel to make sure the retry budget is holding. Calls the open breaker rejects never reached the provider, so those are known failures and safe to retry later; only calls that timed out are unknown. Mitigation: leave the breaker open so threads stay free, let half-open probes run at low rate, show customers a "payment pending, we will confirm by email" state, and let the reconcile job query the provider (by idempotency key or merchant reference, if the provider supports that lookup) and complete or cancel each pending order. Follow-up fix: shorten the half-open probe interval during partial brownouts, add an alert on the count of pending-payment orders older than 15 minutes, and take the second-provider decision to the quarterly review with the incident data.
Architect's notes
Measure the dependency graph before choosing patterns. List every call in the request path, mark critical or optional, and record each one's real p99. Most teams discover that two or three optional calls sit on the critical path.
In design review, ask what the user sees when each dependency is down. If nobody can answer in one sentence, the fallback does not exist yet.
A breaker without an owner decays. Thresholds drift as traffic changes; assign the owning team, review trips monthly and page on breaker state only for critical dependencies.
The late-discovered cost is idle capacity. Per-dependency pools and standby cells hold unused resources; state that cost up front as an insurance premium.
Unknown outcome is its own state. For money and inventory, model "pending" explicitly and reconcile; do not map a timeout to "failed".
Defer hedging and cells. They pay off at high scale or strict tail targets; breakers, budgets and shedding come first.
Common mistakes
Retrying at every layer. Multiplication turns three retries into dozens of calls. Retry in one place and use a budget.
No timeout, or one huge default timeout. Threads pile up behind a slow dependency. Derive timeouts from measured latency and the remaining deadline.
A circuit breaker with no meaningful fallback. It only turns slow errors into fast errors. Decide what the user sees and make it safe.
Shared pools for critical and optional work. Isolate with bulkheads.
Unbounded queues. They hide overload and create huge latency. Bound them and shed when full.
Hedging non-idempotent requests. You may charge a card twice. Hedge only safe reads.
Never rehearsing failure. Untested failover and runbooks often do not work. Run game days.
Key takeaways
Slow can be worse than down. A slow dependency exhausts callers' resources and spreads failure upstream.
Contain the blast radius. Bulkheads and cells keep one failure in one compartment.
Propagate a deadline. A timeout budget stops services doing work nobody is waiting for.
Retries are load. Use backoff, jitter, a single retry layer and a retry budget.
Circuit breakers need fallbacks. Count slow calls, set a minimum volume and keep one breaker per dependency.
Shed deliberately. Bounded queues, priority-based shedding and fast 503 or 429 responses beat collapse.
Hedge carefully. It trims tail latency for idempotent reads at the cost of a little extra load.
Test it. Chaos experiments and game days show what your configuration really does.
Practice exercises
Exercise 1 — Starter (30 min): A request passes through a gateway, service A, service B and a database. Each layer retries up to three times (that is, up to four attempts) on failure. If the database is completely down, how many attempts reach it for one user request? Then redesign the retry policy so the worst case is at most two attempts at the database, and say which layer keeps its retries.
Exercise 2 — Intermediate (2 hours): Draw the dependency graph of a system you know (or the checkout example). Mark each dependency as critical or optional. For each optional one, write the timeout, the fallback and which pool it uses. Then simulate on paper what happens if one optional dependency begins responding in 20 seconds, with and without a bulkhead, using an assumed request rate and thread pool size.
Exercise 3 — Advanced (half day): Write a game-day plan for a service of your choice. Include a steady-state hypothesis with an SLO-based metric, three experiments of increasing blast radius (latency injection, instance loss, dependency outage), the abort conditions, who is on call, and what you expect to observe for each. Afterwards write a short template for the post-experiment review with columns for expected, observed and action.
Lesson 6 of 14 · Advanced System Design and Case Studies
Learn how circuit breakers, bulkheads, load shedding, retry budgets, hedged requests and chaos experiments keep one failing dependency from taking down a whole system.
In the rest of this lesson
Retries with budgets
Circuit breakers
Bulkheads
Back-pressure and load shedding
Hedged requests
Testing resilience: chaos and game days
Enrolling is free. It unlocks every lesson in Advanced System Design and Case Studies, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.