Everything fails. At scale, everything is failing right now. The design question isn’t whether — it’s what happens next.
Prerequisites: The 8 Fallacies, Availability Time to read: ~24 minutes
📐 Rare events are constant at scale. A disk with a 3-year mean time between failures, in a fleet of 10,000:
10,000 disks ÷ (3 × 365 days) ≈ 9 disk failures per day
Add servers, switches, power supplies, and software bugs, and something in your system is broken right now. Designing for “everything works” is designing for a fleet of one.
The useful skill is naming how things fail, because different failure modes need completely different defences.
Ordered from easiest to hardest to handle.
The node halts and stays halted. It stops responding entirely.
✅ The easiest failure, because it’s unambiguous — well, almost (see failure detection below). Handled by redundancy and health checks.
The node crashes and comes back, possibly with stale state.
🚨 The subtle danger: it comes back believing things that are no longer true. It thinks it’s still the leader. It holds a lock it lost. Its cached view of the cluster is from before the reconfiguration.
Defences: fencing tokens (distributed locking), epoch numbers on leadership (leader election), and re-validating all state on startup rather than trusting what’s in memory or on disk.
Messages are lost. The node is fine; the network drops the packet, or a buffer overflows.
Defences: timeouts, retries, sequence numbers, acknowledgments.
🚨 The one that causes the most real damage. The node responds — eventually. Too slowly to be useful.
Why it’s worse than crashing: a crashed node is removed from the pool automatically. A slow node keeps receiving traffic, and every request to it consumes a caller’s thread, connection, and timeout budget. One slow node can take down healthy callers.
📐 The mechanism: caller has 100 threads, calls the slow node, each call blocks for 30 seconds. Within seconds all 100 threads are stuck and the caller stops serving everything, including requests that don’t touch the slow node.
Defences: aggressive timeouts, circuit breakers, bulkheads, load shedding, and health checks that measure latency, not just liveness. → Resilience Patterns
A node behaves arbitrarily — returns wrong data, lies, sends contradictory messages to different peers. Caused by corrupted memory, disk bit-rot, malicious actors, or severe bugs.
⚖️ Byzantine fault tolerance is expensive (needs 3f+1 nodes to tolerate f faults, versus 2f+1 for crash faults) and almost never used in ordinary systems. It matters for blockchains, aerospace, and adversarial multi-party settings.
🚨 But partial Byzantine behaviour is common and worth defending against: checksums on data at rest and in transit, validating inputs from other services rather than trusting them, and not assuming a peer’s response is well-formed. Silent disk corruption is real and happens at scale.
🚨 This is the deepest idea in the chapter.
You cannot distinguish a crashed node from a slow node, or from a network partition.
All three look identical: no response.
Node A → Node B: "are you alive?"
(silence)
Is B dead? Is B in a 30-second GC pause? Is the network between us down?
A cannot tell. Ever.
This is a theoretical impossibility, not an engineering gap. Chandra and Toueg proved that perfect failure detection is impossible in an asynchronous network. The FLP result showed you can’t even guarantee consensus with one faulty process in a fully asynchronous system.
So all real failure detection is a heuristic, and it forces a trade-off with no correct answer:
Short timeout (1 s) → detect failures fast, but a GC pause triggers a false positive
Long timeout (30 s) → few false positives, but 30 seconds of downtime for a real failure
And false positives are genuinely harmful. Declaring a healthy leader dead triggers an unnecessary election, disrupts traffic, and can cause split brain if the “dead” node doesn’t agree.
Better approaches:
🎙️ “Failure detection is fundamentally a heuristic — we can’t distinguish slow from dead. I’d use indirect probes so we don’t evict a node because of one bad network link, and accept that our timeout is a trade between detection speed and false positives.”
The most common way distributed systems die completely, and it’s worth knowing the mechanism precisely.
5 servers, each at 60% capacity
↓
1 fails
↓
Its traffic redistributes: the other 4 are now at 75%
↓
Latency rises (queueing is non-linear near saturation)
↓
Clients time out and RETRY → effective load rises further
↓
A second server saturates and fails
↓
3 servers now handle 100% → they fail
↓
Total outage, ~90 seconds after the first failure
🚨 Note that retries actively accelerate it. A degraded service receives more load precisely when it can least handle it. This is why retry policy is a load-bearing design decision, not a detail.
Defences:
| Defence | Effect |
|---|---|
| Headroom (run at 60–70%, not 90%) | Survivors can absorb a failure → Utilization |
| Circuit breakers | Stop calling a failing dependency; fail fast |
| Retry budgets | Cap retries at ~10% of traffic, globally |
| Backoff + jitter | Retries spread out instead of synchronizing |
| Load shedding | Reject some traffic to keep serving the rest |
| Bulkheads | One dependency can’t consume all your threads |
| Autoscaling | Add capacity — though too slowly to save you in a 90-second cascade |
🚨 Autoscaling does not prevent cascading failure. Instance startup takes 30–120 seconds, and the cascade completes in under two minutes. Autoscaling handles gradual growth; only headroom and load shedding handle cascades.
A subtler and increasingly-discussed mode: the system stays broken after the trigger is gone.
Normal: 10k QPS, cache hit rate 95%, database handles 500 QPS of misses
Trigger: cache restarts → hit rate drops to 0%
database gets 10k QPS → saturates → requests time out
clients retry → 20k QPS → worse
Trigger removed (cache is back), but:
requests time out before they can populate the cache
so the hit rate stays 0%
so the database stays saturated
→ the system is stuck in the broken state, permanently
🚨 The defining feature: removing the original cause doesn’t fix it. The system found a stable bad equilibrium, sustained by its own retry and cache-miss dynamics.
Escaping requires breaking the loop from outside: shed load aggressively until the cache warms, drop traffic to a fraction, or restart in a controlled way with a warm-up phase.
Prevention: retry budgets, admission control, cache warming before accepting traffic, and explicit degraded modes.
The node is partially broken. It passes health checks and returns errors for 5% of requests, or is slow for one endpoint, or fails only for one shard.
🚨 The hardest failures to detect and the longest outages, because none of your automation triggers: health checks pass, the load balancer keeps routing, no failover happens, and no alert fires. You find out from users.
Why it happens: health checks measure the wrong thing. GET /health returning 200 tells you the
process is running — not that its database connection pool is exhausted, or that one dependency is
timing out.
Defences:
🚨 The assumption that breaks all your availability arithmetic.
Three replicas at 99% each should give 99.9999% (the math). You will not get that, because failures are not independent:
Defences: spread across availability zones and regions; staggered canary deploys so a bad release hits 5% not 100%; no shared fate in credentials or certificate expiry; avoid a single config service everything blocks on at startup; and diversity in critical dependencies (two DNS providers).
🎙️ “Three replicas gives us six nines on paper, but failures correlate — same code, same rack, same certificate. Realistically we’re at three or four nines, so I’d spread across AZs and stagger deploys.”
Assume every dependency will fail. For each one, decide: retry, fail fast, degrade, or queue.
Bulkheads. Separate resource pools per dependency, so a slow one can’t consume everything.
# ❌ One shared pool — the slow service starves everything
pool = ThreadPool(100)
# ✅ Bounded per dependency
payments = ThreadPool(20)
recommendations = ThreadPool(10) # can only ever consume 10
Graceful degradation. Rank features by criticality. Recommendations down → hide the widget. Payments down → that’s an outage. Amazon’s product page renders and sells even when half its services are unavailable.
Fail fast, and fail loudly. A request that will fail should fail in 50 ms, not 30 seconds — the caller can then try an alternative or degrade. Silent failures (a swallowed exception, an empty result treated as “no data”) are far worse than loud ones.
Test failure deliberately. Kill instances, inject latency, partition the network. → Chaos Engineering
| Decision | Gain | Cost |
|---|---|---|
| Short failure-detection timeout | Fast recovery | False positives from GC pauses; unnecessary failovers |
| Long timeout | Stable, few false alarms | Extended downtime for real failures |
| Aggressive retries | Recover from transient failures | Amplify load during degradation; cause cascades |
| Retry budgets | Prevent retry storms | Some recoverable requests fail |
| Headroom (60–70% utilization) | Survives instance loss; good p99 | ~40% more infrastructure cost |
| Deep health checks | Catch gray failures | A shared dependency blip can eject the whole fleet |
| Byzantine tolerance | Survives arbitrary behaviour | 3f+1 nodes; complex; rarely justified |
1. Prove that slow is worse than dead. Three backends behind a load balancer. Kill one — measure the error rate and recovery time. Now instead make one respond in 30 seconds. Watch the entire system degrade far worse than when it was dead. This is the most valuable ten minutes in the chapter.
2. Cause a cascading failure. Five instances at 70% load. Kill one. Watch latency rise on the rest. Add client retries with no backoff. Watch it collapse. Then add a circuit breaker and retry budget and watch it survive.
3. Reproduce a metastable failure. App + cache + database. Warm the cache, then flush it while under load with clients that retry on timeout. Observe that restoring the cache doesn’t recover the system — requests time out before they can repopulate it. Then add load shedding and watch it escape.
4. Build a gray failure. Make one instance return errors for 5% of requests while its /health
endpoint returns 200. Confirm your load balancer keeps sending it traffic and no alert fires. Then
add outlier ejection based on error rate.