Lesson 16 just finished building the measurement toolkit. This lesson uses it to look at something hard to believe if you only hear it described: a system taking itself down with traffic it generates itself, long after the root cause is gone.

Four measurements from the lab. First, a three-tier chain where each tier retries three times turns 30 user requests into 810 hits on the leaf service — exactly 27 times, and no tier did anything wrong. Second, backoff without jitter bursts traffic to 19.6 times the average; adding jitter brings that down to 5.3 times. Third, a circuit breaker cuts calls into a sick dependency from 17 down to 7 and turns 5,291 ms of waiting into 2,173 ms. Fourth, a timeout placed in the wrong spot makes the server do the full 50,000 ms of wasted work — 100 complete responses that nobody receives.

ℹ️ Measurement setup
Apple M1 Max, 10 cores, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Three node:22-alpine containers (app1/app2/app3) playing three service tiers, each 1 CPU and 256 MB; the retry/breaker/deadline logic lives in worker/resilience.js, running in a fourth container, no dependencies.

The circuit breaker sits on the client side — exactly where it belongs in practice: the caller is the one who knows when to stop calling. The breaker and deadline measurements use one sequential client, so the throughput numbers in them are not capacity measurements; what matters there is the number of calls that reach the dependency and the time spent waiting.

17.1 Cascading failure: when a system takes itself down

The propagation mechanism is always the same and doesn't depend on the technology. A dependency slows down — not dead, just slow. Whoever calls it holds a connection and a thread open, waiting. Connections held longer means the pool drains faster. A drained pool means the caller itself starts slowing down, and whoever calls it starts holding connections open longer too. Within a few dozen seconds, a small incident at the bottom tier has propagated all the way up to the user.

The most dangerous trait of a cascade is that it is self-sustaining: after the root cause has been fixed, the system keeps falling over, because what's keeping it down now is the retry traffic piling up. And the biggest amplifier of that traffic is something that looks harmless in code review.

🚨 A cascade outlives the cause that started it
This is what sets a cascade apart from every other kind of incident, and it's what makes it the hardest to handle while on call. You find the root cause — a slow query, a dead node — and you fix it. The system is still down. Because what's holding it down is no longer the root cause, it's the pile of retries feeding on itself.

The consequence is deeply counterintuitive: the fastest way out is usually to actively cut load down to near zero — block incoming traffic entirely, let the queues drain, then reopen gradually. It feels like hurting yourself in the middle of an incident, but without it the system never gets enough breathing room to recover. This is also why the load shedding from Lesson 13 is a rescue tool, not just a preventive one.
layer_retry.js
// This code shows up at EVERY layer, and at every layer it looks reasonable:
// "if the call fails, retry a few times, the network might just be flaky".
for (let attempt = 1; attempt <= 3; attempt++) {
  const r = await callDownstream();
  if (r.ok) return r;
}
throw new Error('downstream failed');

Nobody catches the problem reviewing that snippet, because the problem isn't in the snippet — it lives in the product of the whole chain. The lab builds three real tiers, each running exactly that code, then counts how many times the leaf service gets called:

ket_qua_amplify.txt
$ ./tools/resilience-test.sh amplify 30 0
  30 request nguoi dung · chuoi 3 tang · moi tang thu lai 3 lan · budget=0
     app1 leafHits=810 retry=60  budgetDenied=0
     app2 leafHits=0   retry=540 budgetDenied=0
     app3 leafHits=0   retry=180 budgetDenied=0
  (30 x 27 = 810 lan dap vao service tan cung)

810 calls for 30 requests. The factor of 27 = 3 × 3 × 3, and it lands right when the leaf service is weakest — because that very weakness is what's generating the retries in the first place. This is a pure positive feedback loop: the weaker it gets, the more it gets called; the more it gets called, the weaker it gets.

Each tier retries 3 times — no tier did anything wrong 1 request Tier 1 (gateway) retry × 3 ×3 Tier 2 (BFF) receives 3 · retry × 3 ×3 Tier 3 (service) receives 9 · retry × 3 ×3 27 times leaf service Traffic at each tier (real measurements with 30 user requests): into tier 1: 30 into tier 2: 90 into tier 3: 270 into the leaf service: 810 Same scenario, with a 10% retry budget enabled (real measurements) 69 times — instead of 810. 11.7 times fewer. The budget limits by the RATIO of retries to total traffic, so when the whole system is failing the door closes — very different from "3 retries max," which still lets the multiplication happen exactly when it shouldn't.
The factor of 27 doesn't live in any single tier's code — it only exists at the whole-system level.
⚠️ Pitfall: retrying at multiple tiers at once
The practical rule is surprisingly simple: only one tier should retry, and it should be the tier closest to the user that still has enough context to decide. Every other tier should fail and return the error immediately.

The problem is that nobody violates this rule on purpose — it happens because different teams write different tiers, and each team only sees its own. Worse, retries are often already built into the HTTP client library with defaults turned on, so they never appear in any line of code to review. The only reliable check: count how many times the leaf service gets called for one request, exactly as measured above.

17.2 Getting retries right: conditions, backoff, and jitter

Three conditions must hold at the same time for a retry to be safe. Missing any one breaks it:

Condition Why How it breaks if skipped
The error must be transient Timeout, 503, dropped connection — something that could be different next time Retrying a 400 or 404 just wastes resources; it will fail identically next time
The operation must be idempotent A retry means the operation may run twice (Lesson 11) Charging twice, sending an email twice — and you have no way of knowing it happened
There must be backoff with jitter Retrying immediately just adds load exactly when the system is already weak A synchronized wave of retries takes down the server right as it recovers

The third condition is the one most often done halfway: plenty of systems have backoff but no jitter. The consequence is very concrete — when a service dies and comes back, every client that hit the error at the same moment also waits exactly 100 ms, then exactly 200 ms, then 400 ms. Every one of those points is a synchronized wave.

The lab fails 200 clients at once and lets them retry 4 times, both modes using the same backoff formula:

Mode Peak per 20 ms Mean per 20 ms Burst factor Duration
Backoff without jitter 185 9.4 19.6× 1,694 ms
Backoff with jitter 65 12.3 5.3× 1,302 ms

The column worth staring at is the burst factor — how many times the peak is above the average. No jitter gives 19.6 times: a server that just recovered has to absorb a spike nearly 20 times the average load, and that spike is usually enough to kill it again, producing a crash–recover–crash loop. Adding jitter brings the factor down to 5.3 and the peak down to a third.

💡 Jitter also finishes everything sooner
An easy-to-miss detail in the table above: the jittered mode finishes after 1,302 ms while the non-jittered mode takes 1,694 ms. That sounds backwards — adding randomness makes it faster?

The reason: "full jitter" waits a random amount within [0, backoff] instead of exactly backoff, so the expected wait is only half as long. You get both benefits at once: more evenly spread load and a lower average latency. This is one of very few places in this series where a single change improves both sides of a trade-off.
200 clients fail at once — retry traffic over time NO JITTER — peak 185 / 20 ms · burst factor 19.6× 100ms 300ms 700ms 1500ms 4 tall spikes, empty in between WITH JITTER — peak 65 / 20 ms · burst factor 5.3× spread evenly, and finishes sooner (1,302 vs 1,694 ms) Same backoff formula, same number of attempts. The only difference: waiting exactly the backoff, or waiting a random amount within [0, backoff].
The shape of the traffic, not the total volume, is what decides whether the server survives.

17.3 Timeouts and deadlines: don't do work for a request that's already dead

Every network call needs a timeout. That sentence sounds obvious enough to skip, but the default in a great many HTTP libraries is infinite — meaning your default is also infinite, unless you've explicitly set one. A call with no timeout holds a connection open indefinitely, and that is exactly the first step of the cascade mechanism in section 17.1.

But having a timeout isn't enough on its own; timeouts must shrink with depth. If the outer tier waits 1 second while the inner tier waits 3, the outer tier gives up first while the rest of the chain keeps grinding away on a request nobody is waiting for anymore. The lab measures exactly that situation: the client gives up after 100 ms, the server works for 500 ms.

ket_qua_deadline.json
{
  "role": "deadline",
  "soRequest": 100,
  "timeoutPhiaClient": 100,       // client gives up, all 100 requests
  "serverVanLamViec": 100,        // server STILL does all 100 x 500ms of work
  "phanHoiKhongAiNhan": 100,      // 100 complete responses, nobody receives them
  "congToiMs": 50000              // 50 seconds of CPU and I/O for nobody
}

The scariest part of this number is that it's invisible on the dashboard. From the server's point of view, those 100 requests completed normally, no errors, processing time exactly as designed. From the user's point of view, all 100 failed. Both sides are looking at the same system and seeing opposite truths — and the side with the dashboard is the one looking at it wrong.

The fix is called deadline propagation: instead of every tier setting its own timeout, the first tier computes an absolute point in time by which the request must finish, and passes that point downward. Every tier checks it before doing work, and bails immediately if it's already past. The result: nobody does work for a request that's already dead.

⚠️ Pitfall: an outer timeout shorter than an inner one
This is the most common misconfiguration, and it usually happens by accident: each team sets a reasonable timeout for its own service, and nobody looks at the whole chain. The result is that all the resources further down the chain get burned producing a result nobody receives — and worse, if the outer tier then retries after timing out, you now have both problems at once: wasted work multiplied by the amplification factor from section 17.1.

The cheapest check that almost nobody does: draw the timeout of every hop on one timeline. They must shrink from outer to inner, and the sum of a tier's children's timeouts must be smaller than the parent's. If you've never drawn that, chances are good you have at least one spot backwards.

17.4 Circuit breakers and bulkheads

Retrying assumes an error is temporary. Once a dependency is genuinely broken, that assumption is wrong — and every retry just adds another wait before receiving the same error again. A circuit breaker recognizes that and stops calling entirely for a while, giving the dependency room to breathe and saving itself from sitting there waiting.

Circuit breaker — three states and the conditions that transition between them CLOSED lets calls through · counts errors OPEN rejects IMMEDIATELY · never calls downstream HALF-OPEN lets exactly N probe requests through N consecutive errors reached wait time elapsed probe SUCCEEDS → closes again probe FAILS → reopens IMMEDIATELY Missing the "probe fails → reopen immediately" arrow is a real bug hit while building this lab The breaker gets stuck in half-open forever once it runs out of probes: it rejects EVERY request permanently, even after the dependency has recovered — and completely silently.
Four arrows, and the one most often forgotten is the one that keeps the breaker from ever recovering on its own.

The lab kills a dependency for the first 5 seconds (every call hangs 300 ms then returns 503), then lets it recover:

Calls into the sick dependency Time wasted waiting Immediate rejections Recovers after
No breaker 17 5,291 ms 0 299 ms
With breaker 7 2,173 ms 407 188 ms

Read the table both ways. For the dependency: calls hitting it drop 59% exactly when it needs quiet the most. For the caller itself: 5.3 seconds of waiting turns into 2.2 seconds, and 407 requests get an answer immediately instead of hanging — meaning threads and connections get freed up, which is exactly where the cascade gets stopped.

Bulkheads solve a sibling problem: even with a breaker, if every dependency shares one connection pool, a slow dependency can still drain the pool before the breaker has a chance to open. The fix is to give each dependency its own pool — exactly like the watertight compartments on a ship the name evokes: one flooded compartment doesn't sink the whole vessel.

🔬 Why a slow dependency drains a pool so fast
The number is easy to compute and worth being startled by. Say your service has a pool of 50 connections and serves 500 requests/second. Normally each call takes 10 ms, so by Little's Law the connections in use are 500 × 0.01 = 5 — plenty of room in 50.

Now the dependency slows to 2 seconds (not dead, just slow). The same traffic now needs 500 × 2 = 1,000 connections — 20 times capacity. The pool drains in under a hundred milliseconds, and from that second on, every other request, including requests completely unrelated to the slow dependency, queues up waiting for a connection.

That is the entire argument for a bulkhead: if the slow dependency only gets 10 connections out of its own dedicated pool, it drains its own pool and stops there — the other 40 connections keep serving everything else normally. The limit isn't there to protect the dependency, it's there to contain the damage once it breaks.
⚠️ Pitfall: a breaker scoped too broadly, or half-open letting through too many probes
A breaker for the entire service means one broken endpoint blocks every other endpoint — including perfectly healthy ones. The right scope is usually one breaker per dependency, sometimes per important endpoint.

At the other end, half-open letting through too many probe requests at once is a reliable way to kill a dependency that just barely came back up — it recovered at 10% capacity and you just threw 100% of the load at it. The lab uses exactly 2 probe requests. That small number is deliberate: half-open's job is to ask one question, not to reconnect the service.

17.5 Graceful degradation and chaos engineering

The three sections above are all about not making the system worse. This one is about making it still usable once a part is already broken. The principle: a degraded result is almost always better than an error page.

  • A product page loses its personalized recommendations — you can still buy something.
  • Cached data that's 10 minutes stale instead of an error — on most screens, nobody notices.
  • Hide the view count when the stats service is dead — nobody's there for that number.
  • A safe default value instead of calling the config service — as long as that default is tested.

The precondition is identical to prioritized load shedding in Lesson 13: you need to know in advance which paths have to survive no matter what, and which parts are fine to switch off. That's a product decision, not a technical one, and it has to be made before the incident.

⚠️ Pitfall: the fallback path calls the very thing that's dying
A familiar pattern: "call the recommendations service; on error, read a default recommendation from the database." Sounds reasonable — until you realize the recommendations service is dying because the database is overloaded. At that point, the fallback path adds even more load to the exact component that's already gasping, turning a local incident into a system-wide one.

A fallback must depend on fewer things than the primary path, not on other things entirely. A constant value embedded in code is the best fallback there is, because it depends on nothing.

A close relative of this pitfall: a failover mechanism that has never been drilled. A backup path that hasn't run in six months will also be broken by the time you need it — credentials expired, config drifted, or nobody remembers how to switch it on. That is exactly why chaos engineering exists: deliberately inject failures during business hours, when everyone is alert, instead of letting failure find you at 3 a.m.

Reproduce the measurements yourself

reproduce_measurements.sh
cd blog/sysdesign/sysdesign-lab

# --- Retry amplification: 3-tier chain, each tier retries 3 times ---
./tools/resilience-test.sh amplify 30 0   # 810 hits on the leaf service (27x)
./tools/resilience-test.sh amplify 30 1   #  69 hits with a 10% retry budget enabled (11.7x fewer)

# --- Retry storm: with and without jitter ---
./tools/resilience-test.sh jitter
#   no jitter: peak 185/20ms · mean 9.4  · burst factor 19.6x · lasts 1,694ms
#   jitter   : peak  65/20ms · mean 12.3 · burst factor  5.3x · lasts 1,302ms

# --- Circuit breaker: dependency dies for 5s then recovers ---
./tools/resilience-test.sh breaker
#   no breaker: 17 calls downstream · 5,291ms wasted waiting ·   0 immediate rejections
#   breaker   :  7 calls downstream · 2,173ms wasted waiting · 407 immediate rejections

# --- Timeout placed in the wrong spot ---
./tools/resilience-test.sh deadline
#   100 client-side timeouts · server STILL does all 100 · 50,000ms wasted work

In summary

Cascading failure isn't a type of error, it's an operating mode a system falls into, and it's self-sustaining after the root cause is gone. The fuel that feeds it is retries: three tiers, three retries each, turning 30 requests into 810 hits on the leaf service — exactly when that service is weakest. No tier did anything wrong; the factor of 27 only exists at the whole-system level, so the only way to catch it is counting how many times the leaf service gets called per request.

Three fixes, each solving a different part. Retry budgets stop the amplification itself: 810 down to 69, 11.7 times fewer, because it limits by ratio rather than by count. Jitter stops the synchronized wave: burst factor 19.6 times down to 5.3, and it finishes sooner too. Circuit breakers stop the waiting: 5.3 seconds of waiting becomes 2.2 seconds, 407 requests get an answer immediately instead of hanging — and that's exactly where threads and connections get freed.

On timeouts, the number worth remembering is 50,000 ms of wasted work: 100 complete responses nobody receives, and nothing on the server's dashboard shows it — from its side, every request completed normally. Timeouts must shrink from outer to inner, and the correct way is propagating an absolute deadline rather than letting each tier pick its own number.

Finally, a lesson that came from writing this lab itself: the first version of the circuit breaker was missing the "probe fails → reopen immediately" branch, and it got stuck in half-open forever — rejecting every request permanently even after the dependency was healthy again, without throwing a single error. Resilience mechanisms are code too, and code has bugs; the difference is this kind of bug only shows up exactly when you need it most. That is the entire argument for chaos engineering.

Lesson 18 is the last one: put it all together into a real system, run it, measure it, find the bottleneck, fix it, then measure again — with before-and-after numbers for every step, so it's clear which steps were worth it and which just added complexity.

📖 References

Download the practice source code

The resilience toolkit used in the lab: a three-state circuit breaker, backoff with and without jitter, deadlines propagated across tiers — every number in this lesson comes from this file:

Download resilience.js (circuit breaker, jitter, deadline — 0 dependencies)

Related lessons in this series

Lesson 16: Observability — Metrics, Logs & Tracing Lesson 18: Capstone — Design & Run a Real System Back to the System Design roadmap

Comments