Skip to content

    System Design

    System Design: Resilience Patterns

    Learn how circuit breakers, bulkheads, load shedding, retry budgets, hedged requests and chaos experiments keep one failing dependency from taking down a whole system.

    By Published 14 min read

    Share

    This lesson is part of Advanced System Design and Case Studies. Enroll for free, then sign in, to read it in full.

    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:

    1. Detect: How quickly do we notice a dependency is unhealthy?
    2. Contain: How do we stop the failure from consuming shared resources (threads, connections, memory, CPU)?
    3. 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

    How a slow recommendation database cascadesUsers to Web tier; Web tier to Order service; Order service to Payment service; Order service to Recommendation service; Recommendation service to Overloaded database (queries)UsersUsersWeb tierWeb tierOrder serviceOrder servicePayment servicePaymentserviceRecommendation service: optionalRecommendationserviceoptionalOverloaded database: 30 s per callOverloadeddatabase30 s per callqueries

    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

    Checkout starting architectureMobile and web clients to Envoy; Envoy to Checkout service; Checkout service to Recommendation service; Checkout service to Inventory service; Checkout service to Payment provider API (HTTPS); Recommendation service to Redis; Inventory service to PostgreSQLEXTERNALMobile and web clientsMobile and webclientsEnvoy: retries onEnvoyretries onCheckout service: one shared poolCheckoutserviceone shared poolRecommendation serviceRecommendationserviceInventory serviceInventoryservicePayment provider APIPaymentprovider APIRedis: feature storeRedisfeature storePostgreSQLPostgreSQLHTTPS

    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 Survives a provider outage Contract, integration, reconciliation, ongoing cost 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.

    Enroll to keep reading

    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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    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.

    Join engineers building production AI systems

    Comments