Skip to content

    System Design

    QuickCart Stage 9: Surviving Failure Without Losing Orders

    Make QuickCart survive slow dependencies, lost zones and stale leaders with timeouts, breakers, degradation, failover, Raft, fencing tokens and chaos tests.

    By Published 24 min read

    Share

    This lesson is part of Backend System Design Project: Build QuickCart. Enroll for free, then sign in, to read it in full.

    What you'll learn: By the end of this stage you will be able to design QuickCart so that a slow recommendation service, a failing payment provider, a lost availability zone or a paused scheduler costs a feature or a few seconds, not an order. You will set timeouts and retry budgets, place circuit breakers and bulkheads, rank what degrades first, define health checks, choose a failover and disaster-recovery level, and explain consensus, locks, clocks, gossip, idempotency and chaos experiments.

    A ship stays afloat not because the sea is calm but because a hole floods one compartment instead of the whole hull. The earlier stages made QuickCart faster and bigger. This one makes it hold together when parts break, because at this size parts always break.

    It continues from Stage 8, where the monolith became services. More services means more network calls, and every call can fail. The level lessons linked under Related reading go deeper; here each idea is explained and then applied to one system.

    Where QuickCart is now

    After Stage 8, QuickCart is a set of services behind an API gateway: Catalogue, Cart and Checkout, Orders, Payments, Inventory, Delivery and Notifications. Checkout is asynchronous (Stage 5) and payments use idempotency keys (Stage 6). Every number below is an illustrative assumption from the course's shared estimates.

    • Browsing peaks at about 2,300 requests per second and checkout at about 58 per second (Stage 1).
    • Suppose the SLO is 99.9 percent checkout success per 30-day month. The error budget is 0.1 percent of 43,200 minutes, about 43 minutes.

    Three things now threaten that budget.

    1. Slow, not dead. Recommendations is optional, yet the cart page calls it on every view. When its feature store slows to 20 seconds per call, cart threads wait, the shared pool fills, and checkout stops although Payments and Inventory are healthy.
    2. Retries that multiply. The gateway, Checkout and Payments each retry three times, which is four attempts per layer. One checkout can become 4 x 4 x 4 = 64 provider calls, so 58 checkouts per second can become 58 x 64 = 3,712 calls per second to a provider that is only struggling.
    3. Single points. One availability zone (AZ, an isolated data-centre location inside a region) holds the Orders primary database, the courier scheduler and the metadata store.

    Figure 1. Before: one slow optional dependency sinks checkout

    Before Stage 9: a slow recommendation feature store stalls checkoutShoppers to API gateway; API gateway to Cart and Checkout; Cart and Checkout to Payments; Cart and Checkout to Inventory; Cart and Checkout to Recommender (per view); Recommender to Feature storeShoppersShoppersAPI gateway: retries onAPI gatewayretries onCart and Checkout: shared poolCart andCheckoutshared poolPaymentsPaymentsInventoryInventoryRecommender: optionalRecommenderoptionalFeature store: 20 s per callFeature store20 s per callper view

    How to read this: the red box is slow, not down. Cart and Checkout waits on the Recommender (the Recommendations service) with the same threads it needs for payment and stock calls, so the amber boxes stop working while Payments and Inventory stay healthy.

    The core idea

    Resilience is deciding in advance what fails, how far the failure spreads, and what the shopper sees. Each pattern answers one question.

    1. Bound it: how long do we wait and how often do we try? (timeouts, retries)
    2. Contain it: how does one failure avoid using up shared threads and capacity? (breakers, bulkheads, degradation)
    3. Replace it: what takes over when a machine, zone or region is gone, and how much data can we lose? (health checks, failover, disaster recovery)
    4. Agree and repair: what stays correct when copies disagree or messages repeat? (consensus, locks, clocks, gossip, idempotency)

    Chaos engineering then checks that all four work. QuickCart ranks its features: the order path is sacred and everything else may be dropped.

    Calls that fail slowly

    Timeouts

    A timeout is the longest a caller waits for an answer. With none, a call can wait forever, and "forever" times the request rate is an exhausted thread pool. A timeout is a decision about how long the answer is still useful. Set both a connect timeout (opening the connection) and a request timeout (waiting for the reply).

    To choose a value, the Amazon Builders' Library suggests picking an acceptable false-timeout rate (its example is 0.1 percent) and using the matching percentile of the downstream's measured latency, here p99.9.

    Timeouts must also nest. The shopper's request has a deadline, and each call passes down the time remaining, so an inner timeout is always shorter than the outer one. If "place order" must answer in 4 seconds (illustrative), the provider call inside it must time out in under 3. A 30-second provider timeout inside a 4-second request is work nobody waits for.

    Retries, exponential backoff and jitter

    A retry repeats a failed call. It helps with brief faults such as a dropped packet and hurts under overload, because it adds load to a service already struggling. Four rules keep retries safe.

    1. Retry only what is safe to repeat. Reads are safe. RFC 9110 (section 9.2.2) defines PUT, DELETE and the safe methods (GET, HEAD) as idempotent, meaning a repeat leaves the same state; POST is not. "Charge the card" is safe to repeat only with an idempotency key, a client-chosen token that lets the server return the first result for a repeat (Stage 6).
    2. Retry only retryable errors. A timeout, reset or 503 may pass; a 400 never will.
    3. Back off exponentially with jitter. Exponential backoff doubles the wait after each failure. Jitter randomises it so thousands of clients do not retry together. The "full jitter" variant from the AWS Architecture Blog waits a random time between zero and the current ceiling.
    4. Retry at one layer, with a budget. The Google SRE book shows three retries at each of three layers giving up to 4^3 = 64 attempts, and recommends a retry budget (its example: 60 retries per minute per process) and randomised exponential backoff. When the budget is spent, stop retrying, because that is when failures are widest.
    // Full jitter: wait a random time between 0 and a ceiling that doubles per attempt.
    function backoffMs(attempt: number, baseMs = 100, capMs = 5000): number {
      const ceiling = Math.min(capMs, baseMs * 2 ** attempt);
      return Math.random() * ceiling;
    }
    // attempt 0 waits up to 100 ms, attempt 3 up to 800 ms, attempt 10 is capped at 5000 ms.

    The 100 ms base and 5 s cap are teaching values. QuickCart's rule (illustrative): only Payments retries provider calls, at most twice, only with an idempotency key and only while retries stay under 10 percent of calls. The gateway and Checkout do not retry. The worst case drops from 64 times normal load to about 1.1 times.

    Figure 2. One payment call with a timeout, one jittered retry and the same key

    sequenceDiagram accTitle: A payment call with timeout and retry accDescr: Payments calls the provider with an idempotency key, the first attempt times out, Payments waits a random time and retries with the same key, and the provider answers once. participant C as Checkout participant P as Payments participant X as Provider C->>P: authorise order 8812 with deadline 4000 ms P->>X: charge with key K, timeout 1500 ms X--xP: no reply in time Note over P: wait random 0 to 200 ms, retry budget allows it P->>X: charge with the same key K X-->>P: approved, one charge only P-->>C: approved

    How to read this: the first attempt's outcome is unknown, because the provider may have charged before the reply was lost. Reusing key K makes the second attempt safe; without it a retry could charge the shopper twice.

    Containing the damage

    Circuit breaker

    A circuit breaker wraps calls to a dependency and watches the results. As Martin Fowler describes it, it has three states.

    • Closed: calls pass through and failures are counted.
    • Open: failures crossed the threshold, so calls fail at once without touching the dependency. Threads are freed and the dependency can recover.
    • Half-open: after a wait, a few trial calls go through. Success closes the breaker; a failure opens it again.

    Figure 3. The breaker state machine

    stateDiagram-v2 accTitle: Circuit breaker states accDescr: A breaker is closed until the failure rate crosses a threshold, then open for a wait period, then half-open for trial calls, which close or reopen it. [*] --> Closed Closed --> Open: failure or slow-call rate over threshold Open --> HalfOpen: wait period over HalfOpen --> Closed: trial calls succeed HalfOpen --> Open: a trial call fails

    How to read this: the breaker moves only on measured outcomes. Open is the protective state: it trades a slow failure for a fast one.

    Thresholds set its behaviour. An illustrative setup: open when, in the last 10 seconds, at least 20 calls were made and at least 50 percent failed or exceeded the timeout. The minimum call count stops one failure on a quiet minute from tripping it. Count slow calls as failures, since a 20-second success is not healthy, and keep one breaker per dependency, never a global one.

    Worked example: the Recommendations breaker sees 20 calls in 10 seconds and 12 time out. 12 of 20 is 60 percent, so it opens for 30 seconds and the cart page shows a popular-items list without spending threads. Then 3 trial calls are allowed; if they succeed it closes.

    What the shopper sees while it is open is the fallback: cached data, a default, an empty list, a queued action or a clear error. Stale recommendations are harmless; a stale price at payment is not.

    Bulkhead

    A bulkhead gives each dependency a fixed share of a scarce resource, such as its own thread or connection pool, so one cannot take it all.

    Little's law shows why: calls in flight equal arrival rate times latency. With 58 checkouts per second and a 0.4 s payment call (illustrative), about 23 calls are in flight. If the provider slows to 8 s, the same traffic wants 58 x 8 = 464. With one shared pool of 200 threads, payments alone drain it. With a payments pool of 50, the 51st concurrent call is rejected at once and Inventory and Catalogue keep their threads. The cost is idle capacity in normal times, an insurance premium.

    Graceful degradation

    Graceful degradation offers a simpler service instead of none, using a ranking decided in advance. QuickCart drops in this order.

    Order Feature Degraded mode Why it is safe
    1 Recommendations Static "popular near you" list No order depends on it
    2 Search ranking Plain text match Shoppers still find products
    3 Notifications Queue and deliver late The order still happens
    4 Live courier map Last known location with its age Status stays correct
    Never Checkout and payment Accept and queue if downstream is slow This is the business

    Checkout degrades only at its edges: loyalty points and receipts move to the asynchronous pipeline.

    Figure 4. What to shed, and when

    flowchart TD accTitle: Degradation ladder for QuickCart accDescr: A decision flow that sheds recommendations first, then search ranking, then delays notifications, and never sheds checkout. S["Dependency slow or load above limit"] --> R{"Recommendations breaker open or load high?"} R -->|yes| R1["Serve static popular list"] R -->|no| K["Normal service"] R1 --> Q{"Still over limit?"} Q -->|yes| Q1["Use plain text search ranking"] Q -->|no| K Q1 --> N{"Still over limit?"} N -->|yes| N1["Queue notifications and delay them"] N -->|no| K N1 --> C["Protect checkout, reject lower-priority requests with 503"]

    How to read this: each step sheds less valuable work first, and the last step reserves what remains for checkout. Each step is a switch an engineer or an automatic rule can flip.

    The SRE book notes degraded modes rarely run, so exercise them on purpose.

    Knowing what is healthy

    Health checks: liveness, readiness and startup

    A health check is a small request asking an instance whether it is fit for a purpose. The Kubernetes documentation separates three questions (Stage 10 covers Kubernetes itself).

    Check Question When it fails
    Liveness Is the process stuck beyond recovery, such as a deadlock? The kubelet restarts the container
    Readiness Can it take traffic now? The pod leaves the Service endpoints; no restart
    Startup Has it finished starting? The container is killed and restarted; liveness and readiness wait until it passes

    The classic mistake is a liveness probe that depends on other services. The documentation warns that a wrongly built liveness probe can cause cascading failures. If Payments' liveness check called the database and the database slowed, every Payments pod would restart at once. Keep liveness about the process. Put a dependency in readiness only if the pod cannot serve without it, and remember that if every pod shares one database, a blip removes them all together.

    startupProbe:
      httpGet: { path: /healthz/started, port: 8080 }
      periodSeconds: 5
      failureThreshold: 30      # allows 5 x 30 = 150 s to start (illustrative)
    livenessProbe:
      httpGet: { path: /healthz/live, port: 8080 }
      periodSeconds: 10
      failureThreshold: 3
    readinessProbe:
      httpGet: { path: /healthz/ready, port: 8080 }
      periodSeconds: 5
      failureThreshold: 2

    The paths are QuickCart's own names. Load balancers do the same job at the edge by probing targets (load balancing).

    When a machine, a zone or a region goes away

    Redundancy and failover

    Redundancy means more than one of everything that can fail; failover is the switch to the replacement. Reason in failure domains: process, machine, AZ, region.

    Failure domain QuickCart answer Cost
    Process or machine Many stateless replicas on different machines Spare capacity
    AZ Services, caches and brokers in 2 or 3 AZs; Orders database with a synchronous standby in another AZ Cross-AZ charges; slower commits
    Region A second region (next section) Most money and complexity

    State needs more care than servers. With synchronous replication a commit is acknowledged only after the standby has it, so losing the primary's zone loses no committed order, at the price of write latency; asynchronous replication can lag (replication). Failover can also be wrong: the AWS disaster-recovery paper warns that automatic region failover "should be used with caution", because a false alarm itself causes loss, and many teams automate every step but keep a human pressing the button. Inside a region, automatic promotion is normal, but it needs agreement on who the new primary is, which is consensus.

    Disaster recovery: RPO and RTO

    Disaster recovery (DR) is surviving the loss of a whole site. RPO (recovery point objective) is how much recent data you can lose, as time. RTO (recovery time objective) is how long you can be down. Tighter targets cost more. The AWS whitepaper lists four patterns:

    Pattern Runs in region B Recovery Cost
    Backup and restore Backups only Slowest Lowest
    Pilot light Data replicated, core services off Switch on, scale Low
    Warm standby Scaled-down copy running Scale up Medium
    Multi-site active-active Full copy serving traffic Near zero for most disasters Highest

    In active-passive, region B waits; in active-active, both serve, so writes from both sides need a rule: route each shopper's writes to one home region (Orders is already sharded by shopper, Stage 4) or accept last-writer-wins and its loss risk. Replication also copies corruption, so keep point-in-time backups.

    QuickCart's targets, all illustrative:

    Data RPO RTO Pattern
    Orders and ledger Zero for an AZ loss; under 1 minute for a region loss 30 minutes Sync standby, async replica in B, warm standby
    Catalogue, search index 1 hour (rebuildable) 2 hours Pilot light
    Courier locations Minutes (transient) 15 minutes Re-ingested from couriers' apps
    Analytics warehouse 24 hours 1 day Backup and restore

    Set targets per data type, and test the plan.

    Agreeing in a cluster

    Consensus and leader election (Raft)

    Consensus is a group of nodes agreeing on a value or an order of values despite crashes and delays; leader election is agreeing who is in charge. QuickCart needs it for a small metadata store: the shard map from Stage 4, configuration, the flags that switch degradation on, and the lease naming which scheduler instance assigns couriers.

    Raft, from Ongaro and Ousterhout's 2014 paper "In Search of an Understandable Consensus Algorithm", works like this at concept level.

    1. Roles and terms. Nodes are followers, candidates or a leader. Time is split into numbered terms, each with at most one leader. Messages carry the sender's term, and a node that sees a higher term steps down, which is how a stale leader is recognised.
    2. Election. A follower that hears no heartbeat for a randomised timeout becomes a candidate, raises the term, votes for itself and asks for votes. Each node gives one vote per term, only to a candidate whose log is at least as up to date as its own. A majority wins.
    3. Log replication. The leader appends each command to its log and sends it out. Once a majority has stored it, it is committed.

    Take QuickCart's three-node metadata store, one node per AZ. A majority is 2 of 3, so it survives one loss; five nodes survive two. If the leader's AZ is cut off, the other two elect a leader in a higher term and keep committing. The isolated node cannot reach a majority, so it cannot commit writes: Raft chooses consistency over availability in a partition (CAP).

    The cost is that every write needs a majority round trip, so the store is small and slow compared with a database. Use it for coordination, never for orders, and run a proven system (etcd, ZooKeeper, a managed service) rather than your own. See Consensus and Leader Election.

    Distributed locks and fencing tokens

    A distributed lock lets one process at a time do something, such as assign courier 17 to an order. The tempting recipe is a Redis lock: SET resource_name my_random_value NX PX 30000 (set if absent, expire after 30 seconds), released only if the stored value is still yours. The Redis documentation names the limits:

    • Expiry. If the holder pauses (garbage collection, a stalled VM) past the TTL, a second client takes the lock and the first wakes still believing it holds it.
    • Failover. Redis replication is asynchronous, so a promoted replica may not have the key and another client can take the lock.
    • Clocks. Redis does not use a monotonic clock for expiry, so a wall-clock jump can shorten a lock.

    Martin Kleppmann's analysis adds the central point: a lock for efficiency (avoiding duplicate work) can tolerate these gaps; a lock for correctness needs a fencing token, a number that rises with every grant. Each write carries it, and the resource keeps the highest token seen and rejects lower ones.

    Figure 5. A paused scheduler is stopped by the fencing token

    sequenceDiagram accTitle: Fencing token on courier assignment accDescr: Scheduler A gets token 33 and pauses, its lease expires, scheduler B gets token 34 and assigns the courier, and A's late write with token 33 is rejected. participant A as Scheduler A participant L as Lock service participant B as Scheduler B participant D as Assignments table A->>L: acquire lease for zone 7 L-->>A: granted, token 33 Note over A: long pause, lease expires B->>L: acquire lease for zone 7 L-->>B: granted, token 34 B->>D: assign courier 17, token 34 D-->>B: accepted A->>D: assign courier 21, token 33 D-->>A: rejected, stale token

    How to read this: A did nothing wrong in its own view; it slept past its lease. The protection lives in the table A writes to, not in A.

    In SQL the check is a conditional update (fence starts at 0), and zero rows changed means rejected:

    UPDATE courier_assignments
    SET courier_id = $1, fence = $2
    WHERE order_id = $3 AND fence < $2;

    Prefer avoiding locks. Most "exclusive" actions are a conditional write. Reserving stock needs no lock if the database decides atomically:

    -- Stage 3 inventory table, with a reserved column added
    UPDATE inventory
    SET reserved = reserved + $3
    WHERE store_id = $1 AND product_id = $2 AND stock - reserved >= $3;

    One row changed means reserved; none means out of stock. Reach for a lock service only when no atomic check exists.

    Clocks and ordering

    Machines do not share a clock. Each physical clock drifts, and NTP corrects it only to limited accuracy, so machines disagree by some skew. Suppose Inventory (clock fast) writes "mango stock = 0" stamped 10:00:05, and a store's app (clock slow) later writes "mango stock = 40" stamped 10:00:03. A last-write-wins store keeps 0 and silently loses the newer write. Use monotonic clocks for durations, and never compare timestamps from two machines to decide what came first.

    What orders events instead is causality: A is before B if A could have influenced B.

    • A Lamport clock is a counter per process: increment per event, attach it to messages, and on receipt take the larger of local and received, plus one. If A happened before B, A's stamp is smaller; the reverse is not guaranteed.
    • A vector clock keeps one counter per process. Comparing two vectors shows "before" or concurrent (neither knew of the other), so a store can keep two conflicting versions for merging instead of dropping one. Vectors grow with the number of writers.
    • A hybrid logical clock (HLC) pairs a physical time with a small counter and never moves backwards. If B's wall clock reads 90 and it receives a message stamped (100, 0), B's clock becomes (100, 1) and its next event is (100, 2), though its wall clock still says 90. It stays near real time and respects causality, which suits QuickCart's order-event timestamps (real systems add drift bounds).

    So QuickCart updates carry versions or a status rank, not bare timestamps. See IDs, Clocks and Ordering.

    Gossip

    Gossip shares state across a large cluster without a coordinator. Each interval, a node exchanges what it knows with a random peer, which passes it on, so news spreads like a rumour in roughly logarithmic rounds of cluster size (a classic result from the epidemic-algorithms literature).

    Apache Cassandra's documentation shows it: each node gossips every second with a random peer (and sometimes a seed node), tagging state with a (generation, version) pair so old information is ignored. Failure detection is local: each node runs a variant of the Phi Accrual Failure Detector on heartbeats, and "down" is that node's own judgement, not a broadcast. The SWIM paper (Das, Gupta and Motivala, 2002) describes a related design that pings a member directly and, if that fails, asks a few others to ping it before declaring it failed.

    Gossip suits membership and failure detection in big clusters, such as a courier-location store. It is eventually consistent, so do not use it where one answer is needed now, such as who the leader is.

    Making repeated and reordered messages harmless

    Idempotent and commutative operations

    Retries, redelivered events and replicas merging in different orders all deliver messages twice or out of order. Two properties neutralise that. An operation is idempotent if doing it twice equals doing it once, and commutative if order does not matter.

    Operation Idempotent Commutative Safe to retry and merge?
    Set status to "paid" Yes No Retry yes; merging needs a rule
    stock = stock - 1 No Yes Retry no
    Set union, or keep the maximum Yes Yes Both

    Give order statuses a rank (placed, paid, picked, delivered) and apply "take the higher rank". A duplicate or late "paid" event then cannot move a delivered order backwards. Cancellation needs its own explicit rule.

    A G-counter (grow-only counter) keeps one count per node and merges by taking the maximum per node. If node A holds {A: 3, B: 0} and node B holds {A: 2, B: 4}, the merge is {A: 3, B: 4}, value 7, however often or in whatever order merged. CRDTs (conflict-free replicated data types; Shapiro and others, 2011) generalise this. Where commutativity is impossible, use an idempotency key and a deduplication table (Stage 6).

    Proving it works

    Chaos engineering

    Settings are guesses until tested. Principlesofchaos.org defines chaos engineering as experimenting on a system to build confidence that it withstands turbulent conditions. An experiment has five parts.

    1. Steady state: a measurable output, such as checkout success and p99 latency.
    2. Hypothesis: "with recommendations delayed 5 seconds, checkout success stays above 99.9 percent."
    3. Injected event: added latency, a killed pod, a blocked path.
    4. Disproof attempt: compare against a control.
    5. Blast radius: start with one pod or 1 percent of traffic, with a kill switch and abort conditions.

    A game day is a scheduled rehearsal that includes people: detection, paging, communication, rollback. Often the runbook or alert is what breaks. QuickCart starts in staging, lowest risk first.

    Experiment Hypothesis Abort if
    Add 5 s to Recommender Cart shows popular list; checkout unaffected Checkout success falls below the SLO line
    Block egress to the provider Breaker opens; orders held as "payment pending" Any order lost
    Remove one metadata-store node Leader re-elected; no writes lost No leader after the timeout
    Take AZ-1 offline (game day) Database fails over within RTO Any committed order missing

    The failure-mode table

    Failure Detection Effect Mitigation
    Recommendations slow Breaker state, pool saturation, p99 Cart shows fallback list Timeout, breaker, bulkhead, static fallback
    Provider slow Timeout rate, breaker Payments cannot authorise Breaker, "payment pending", idempotent retry, reconcile
    Retry storm Retry ratio over budget Dependency stays down One retry layer, budget, jitter
    Hung pod Failed liveness probe One replica lost Restart; others serve
    Pod not ready Failed readiness probe No traffic to it Removed from endpoints
    AZ lost Probe failures, alerts Capacity and primary DB lost Multi-AZ, sync standby promoted
    Region lost Synthetic checks fail Full regional outage Warm standby; deliberate failover
    Metadata store loses majority No leader, write errors Config and leases frozen 3 or 5 nodes across AZs; last-known config
    Scheduler pauses past lease Lease lost, stale-token rejects Duplicate assignment attempt Fencing token on the write
    Clock skew Offset alerts Updates mis-ordered Versions or HLC
    Duplicate or reordered event Duplicate-key counts Double effect Idempotency keys, rank merge

    Decisions and trade-offs

    Option Gain Cost When it is the wrong choice
    One retry layer with a budget Extra load bounded near 1.1 times One owner must implement it Operation not idempotent, no key
    Per-dependency bulkheads Optional features cannot starve checkout Idle capacity, tuning A service with one dependency
    Breaker with a safe fallback Fast failure, thread protection Threshold upkeep No safe fallback exists
    Multi-AZ with sync standby No committed-order loss in an AZ loss Slower commits, cross-AZ cost Writes that tolerate loss and need speed
    Warm standby region Recovery in minutes Standing cost, drills A day of downtime is acceptable
    Active-active regions Near-zero recovery Conflict design, top cost Before the business asks for it
    Redis lock Simple, cheap Gaps on expiry and failover Correctness without fencing
    Chaos in production Real confidence Real impact risk No alerts or kill switch yet

    In practice: QuickCart at this stage

    NOTE

    A realistic scenario, not a case history. This is a composite educational reference design. It does not describe any real company, and every number is an illustrative assumption.

    The situation

    On a Saturday at 19:00 the Recommender's feature store slows after a data refresh. The cart page keeps calling it, Cart and Checkout runs out of threads, the gateway retries, and checkout success falls to about 80 percent for 25 minutes. That is 20 percent failures for 25 minutes, about 5 of the month's 43 error-budget minutes. Two weeks later a payment-provider brownout makes retries stack across three layers. The brief: survive both, plus an AZ loss, within a quarter.

    Starting architecture

    Figure 6. Starting architecture: shared pools, retries in three places, single points in one AZ

    Stage 8 QuickCart before the resilience workShoppers to API gateway; API gateway to Cart and Checkout; Cart and Checkout to Payments; Cart and Checkout to Recommender; Cart and Checkout to Orders database; Courier scheduler to Orders database (assign)ShoppersShoppersAPI gateway: retries onAPI gatewayretries onCart and Checkout: shared poolCart andCheckoutshared poolPayments: retries onPaymentsretries onRecommenderRecommenderOrders database: AZ-1 onlyOrdersdatabaseAZ-1 onlyCourier scheduler: one instanceCourierschedulerone instanceassign

    How to read this: amber marks a risk the incident exposed: retries at several layers, a shared pool, and single instances in one zone.

    Options considered

    Option What it fixes What it costs Risk
    Lower timeouts only Shorter thread holds One config line Pool still shared; retries still multiply
    Containment package (timeouts, one retry layer, breakers, bulkheads, ladder, probes) Optional features cannot sink checkout Weeks of work, dashboards Bad thresholds cause false trips
    Add multi-AZ and a warm-standby region AZ and region loss Standing cost, drills Untested failover is fiction
    Active-active now Near-zero RTO Conflict design, top cost Not required by the targets

    The decision

    Field Entry
    Context An optional dependency sank checkout, retries multiplied load, and single points sit in one AZ.
    Decision Containment package, multi-AZ, and a warm-standby region at the targets above. Recommender gets its own pool, a 150 ms timeout and a static fallback. Payments owns provider retries (key, 10 percent budget). A three-node metadata store across AZs issues the scheduler lease and fencing tokens.
    Consequences, good A recommendation outage becomes a missing widget; an AZ loss loses no committed order.
    Consequences, bad More configuration, idle capacity, a standing second region, drills to maintain.
    Revisit when Region drills exceed the 30-minute RTO, or the business needs near-zero cross-region downtime.

    Rollout and verification

    1. Run breakers in shadow mode (log only) for a week.
    2. Canary at 5 percent behind a flag, then widen.
    3. Move retries last: enable the Payments budget before disabling gateway retries, or there is a moment with none.
    4. Run the experiments above in staging, then a production game day for AZ-1.

    Dashboards: checkout success and p99, breaker state and pool use per dependency, retry ratio (target under 10 percent), shed requests by priority, metadata-store leader changes, and replica lag.

    The 2 a.m. failure

    At 02:00 AZ-1 loses its network. The page reads "Orders primary unreachable, checkout success 40 percent". The scheduler in AZ-1 is partitioned, not dead.

    Figure 7. AZ-1 is lost and the database fails over

    sequenceDiagram accTitle: Failover after losing an availability zone accDescr: Probes detect that the primary in AZ-1 is unreachable, the standby in AZ-2 is promoted, checkout resumes, and the old scheduler's stale writes are rejected. participant H as Health checks participant P as Primary AZ-1 participant S as Standby AZ-2 participant C as Checkout participant O as Old scheduler H->>P: probe P--xH: no answer H->>S: promote after failure threshold S-->>H: now primary C->>S: orders write S-->>C: committed O->>S: assign courier, token 33 S-->>O: rejected, token 34 is current

    How to read this: the standby already held every committed order because commits waited for it. The old scheduler wakes later with a stale token and does no damage.

    First three checks: the checkout and breaker dashboard (which dependency tripped); promotion status and replica lag (did the standby take over, and was it in sync); the metadata store's leader and term (a new leader in AZ-2 or AZ-3?). Mitigation: let promotion finish, confirm the new primary accepts writes, and shed low-priority traffic while two AZs carry the load. Follow-ups: alert on stale-token rejects and test that two AZs can carry peak load. This is a realistic scenario, not a case history.

    Architecture after this stage

    Figure 8. QuickCart after Stage 9: what this stage added

    QuickCart architecture after Stage 9Shoppers to Health-checked routing; Health-checked routing to API gateway; API gateway to Cart and Checkout; Cart and Checkout to Recommender (fallback); Cart and Checkout to Orders database (writes); Cart and Checkout to Metadata store (leases); Orders database to Sync standby (sync); Orders database to Async replica (async); Health-checked routing to Warm standby (region failover)REGION A, THREE AZSREGION B, STANDBYShoppersShoppersHealth-checked routing: DNS and LBHealth-checkedroutingDNS and LBAPI gateway: 3 AZsAPI gateway3 AZsCart and Checkout: breakersCart andCheckoutbreakersRecommender: static fallbackRecommenderstatic fallbackMetadata store: 3 nodes, RaftMetadata store3 nodes, RaftOrders database: primary, AZ-1Ordersdatabaseprimary, AZ-1Sync standby: AZ-2Sync standbyAZ-2Warm standby: scaled downWarm standbyscaled downAsync replica: region BAsync replicaregion Bfallbackwritesleasessyncasyncregion failover

    How to read this: accent boxes are new or changed in this stage. Normal traffic follows solid arrows through Region A; the red dashed arrows are fallback paths that carry traffic only when something has failed.

    What QuickCart looks like now

    Component Why it exists What it costs
    Timeouts and deadlines Bound waiting Tuning from measured latency
    Retry budget, jitter, idempotency keys Safe repeats, no storms Keys and dedup storage
    Breakers and bulkheads Contain slow dependencies Idle capacity, threshold upkeep
    Degradation ladder and flags Keep checkout alive Fallback paths to build and test
    Probes Restart or remove bad pods Careful endpoint design
    Multi-AZ, sync standby Survive an AZ loss Slower commits, cross-AZ traffic
    Warm standby region Survive a region loss Standing cost, drills
    Metadata store, fencing tokens Safe leader, config, assignment A cluster to run
    Chaos experiments Prove the above Engineer time, risk management

    Architect's notes

    • Draw the dependency graph first. List every call on the critical path, mark it critical or optional and record its real p99. Optional calls often sit on the critical path.
    • Set RPO and RTO per data type with the business. One number for everything is too costly or too weak.
    • Unknown outcome is its own state. A timed-out charge may have succeeded. Model "pending" and reconcile; never map a timeout to "failed".
    • Defer active-active. Add it when a measured requirement demands it; containment and tested failover come first.

    Common mistakes

    • No timeout, or a huge default. Threads pile up behind a slow dependency.
    • Retrying at every layer. Three layers of three retries is 64 attempts.
    • Retrying non-idempotent calls. Double charges.
    • A breaker with no safe fallback. It only fails faster.
    • A liveness probe that checks the database. A blip restarts every pod.
    • A lock without a fencing token. A paused holder corrupts data.
    • Trusting timestamps across machines. Skew silently drops writes.
    • Untested failover. A plan that never ran is a guess.

    Key takeaways

    • Slow is worse than down. A slow dependency drains callers' threads and spreads failure.
    • Bound every wait. Timeouts nest inside a deadline and come from measured percentiles.
    • Retries are load. Use idempotency, jitter, one retry layer and a budget.
    • Contain, then degrade. Breakers and bulkheads limit spread; a ranked ladder decides what to drop, and checkout is never on it.
    • Three probes, three meanings. Liveness restarts, readiness removes from traffic, startup protects slow starts.
    • RPO and RTO are priced business choices. Set them per data type and test the plan.
    • Consensus is for small coordination data. Majorities make leaders safe; partitions favour consistency.
    • Fence your locks, or avoid them. Prefer atomic conditional writes.
    • Do not trust wall clocks for order. Use versions, logical or hybrid clocks.
    • Make messages idempotent or commutative. Then retries and merges are safe, and chaos experiments show it.

    Practice exercises

    Exercise 1: Retry arithmetic (30 min). The gateway, Checkout and Payments each allow 2 retries (3 attempts). How many provider calls does one checkout cause when the provider is down, and how many per second at 58 checkouts per second? Redesign so the worst case is 3 attempts and say which layer keeps its retries.

    Exercise 2: Size a bulkhead (45 min). Use Little's law. The cart service takes 2,300 requests per second and 1 in 10 calls the Recommender at 80 ms. How many calls are in flight normally, and at 10 s? Choose a pool size, a timeout and a shopper-visible fallback.

    Exercise 3: Targets (1 hour). Write an RPO, RTO and DR pattern for six QuickCart data types, including the notification queue. Justify each number from the business.

    Exercise 4: Game day (half day). Plan a game day that takes AZ-1 offline: steady-state metric, hypothesis, abort conditions, who is on call, and a review template with expected, observed and action columns.

    Official documentation

    Enroll to keep reading

    Lesson 9 of 11 · Backend System Design Project: Build QuickCart

    Make QuickCart survive slow dependencies, lost zones and stale leaders with timeouts, breakers, degradation, failover, Raft, fencing tokens and chaos tests.

    In the rest of this lesson

    • Knowing what is healthy
    • When a machine, a zone or a region goes away
    • Agreeing in a cluster
    • Making repeated and reordered messages harmless
    • Proving it works
    • The failure-mode table

    Enrolling is free. It unlocks every lesson in Backend System Design Project: Build QuickCart, 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