Drill 11 — One Downstream Takes 10 Seconds
A home-feed API that calls five services, and what one slow one does to all the others
Time box: 45 minutes. Uses: Chapter 1 — Timeouts and Deadlines, Chapter 2 — Retries and Idempotency, Chapter 3 — Isolation and Blast Radius, Chapter 6 — Graceful Degradation. Background: Q33 — Timeout, deadline, retry budget, Q29 — Retry systems, Q30 — Circuit breaker, JS Ch 7 — Controlling Concurrency, Casebook III: Slow, Flaky, and Falling Over. Sims: load-balancer (Slow server), concurrency-pool (Retry storm).
How to run this drill
Draw the baseline from the prompt before you scroll. For each break, answer "Before you read on" out loud, step through the timeline or run the sim, then open the answer. Every answer runs detect → contain → degrade → recover → prevent.
A dead dependency is the easy case: calls fail fast, and you notice. A slow dependency is worse, because every call to it holds something while it waits: a socket, a slot in a pool, memory for the half-built response, a user staring at a spinner. One tool does most of the arithmetic in this drill, so learn it now. Little's law: the number of things in flight equals the arrival rate times how long each one takes.
in flight = rate × latency
A dependency that goes from 80 ms to 10 s doesn't make your service 125× slower. It makes it hold 125× as many things at once, and whatever runs out first takes everything else down with it.
The prompt
"Design
GET /homefor a content app. The home screen shows the user's header, posts from people they follow, recommended posts, an ad slot and a notifications badge. Each comes from a different service. 3,000 home loads per second at peak, p99 under 400 ms. Then I'll break it."
| Downstream | p50 | p99 | Can the screen live without it? |
|---|---|---|---|
| profile (header) | 15 ms | 40 ms | Mostly: a cached copy is fine |
| posts (following feed) | 50 ms | 120 ms | No: this is the product |
| recommendations | 80 ms | 200 ms | Yes |
| ads | 40 ms | 100 ms | Yes (it costs revenue) |
| notifications (badge count) | 10 ms | 30 ms | Yes |
Other facts you get when you ask: the BFF ("backend for frontend") also serves /search and /profile/:id, about 50 requests/s per pod, which call some of the same services. Traffic peaks at 1.5× around 9 pm. All five services are reached through the service-mesh sidecar on localhost.
The baseline design
A decent 15-minute design: a Node BFF that fans out to all five in parallel and assembles the screen.
// GET /home: the baseline handler
app.get('/home', async (req, res) => {
const u = req.user.id;
const [profile, posts, recs, ads, badge] = await Promise.all([
get(`/profile/${u}`), get(`/posts/feed/${u}`), get(`/recs/${u}`),
get(`/ads/slot?u=${u}`), get(`/notifications/count/${u}`),
]);
res.json({ profile, posts, recs, ads, badge });
});
const get = (path) => fetch(`http://localhost:15001${path}`).then((r) => r.json());
| Component | Choice | Numbers |
|---|---|---|
| BFF | 30 Node pods | 3,000 / 30 = 100 home loads/s per pod, 150 at 9 pm |
| Fan-out | Promise.all over five fetch calls | The screen waits for the slowest of the five |
| Timeouts | None set | Node's fetch waits up to 300 s for response headers by default |
| Connections | All five go through one sidecar address | One shared pool per pod; Node's agent keys its pool by host and port |
| recommendations | 4 instances behind round robin | 3,000 / 4 = 750 calls/s each, at about 75% of what one can do |
The in-flight count on a normal day, per pod: 100/s × (0.015 + 0.05 + 0.08 + 0.04 + 0.01) s ≈ 20 downstream calls open at any moment. Keep that number; every break multiplies it.
Break 1: One recommendations instance gets 5× slower
"One of the four recommendations instances lands on a host with a noisy neighbour. It's 5× slower but up, and it passes its health checks. Your p50 is fine. Your p99 is climbing and won't stop. Why?"
Before you read on:
- Round robin keeps sending that instance a quarter of the calls. Can it finish them?
- Why does p99 keep climbing instead of settling at 5× normal?
- Name one fix in the load balancer and one fix in your BFF.
The sim has 4 servers behind round robin, and at 1 s server B becomes 5× slower. Lock in a prediction, press Play, and watch the p99 line pull away from p50 while B's queue grows. When the answer appears, switch Algorithm to Least-conn or P2C mid-run and watch p99 stop climbing.
Answer
What the sim shows. p99 went from 100 ms to 3.5 s and was still climbing while p50 stayed at 90 ms. Round robin kept sending B one request in four, about 15/s, but at 5× slower B could finish only about 5/s, so its queue grew without limit. Under least connections, p99 held at about 540 ms: B got only 6% of traffic and its queue stayed empty, but requests that did land on B were still slow.
The same in this design. A recommendations instance normally finishes about 1,000 calls/s and gets 750. At 5× slower it can finish about 200/s, so its queue grows by 550 calls every second. Every home load that lands on it waits for the queue. Because Promise.all waits for the slowest call, a quarter of all home loads inherit that wait. That is why p50 is fine and p99 is unbounded.
Detect. Per-instance latency for recommendations (one instance far above the others); the home endpoint's p99 diverging from its p50; traces where the recs span is the long pole (Q55 — Distributed tracing). A health check won't catch it: the instance is up.
Contain. In the load balancer: least-requests or power of two choices (pick two instances at random, send to the one with fewer requests in flight), plus outlier ejection, which removes an instance whose latency or error rate stands far out from its peers. In the BFF: a timeout on recommendations of 250 ms (its p99 is 200 ms), so no home load waits longer than that for it.
Degrade. Calls that hit the timeout render without live recommendations (Break 2 builds the fallback).
Recover. The recommendations team drains the instance and reschedules it on another host.
Prevent. For a read like this you can also hedge: if no answer has arrived after 150 ms (about the p95), send a second copy to another instance and use whichever answers first. Normally about 5% of calls get hedged, so 5% extra load. During this incident a quarter would, so cap hedges at 10% of calls.
The follow-up: "Why not just retry the slow call on another instance after the timeout?"
Answer
Because the timeout is 250 ms and the whole screen has 400 ms. A retry after the timeout starts at 250 ms and needs up to another 200 ms: it finishes after the deadline, so the user never sees its result, and the slow instance still does the first call's work. A hedge sends the second copy before the deadline is spent, at 150 ms, while there's still time to use it. Retries suit failures that are fast; hedges suit latency.
Break 2: All of recommendations now takes 10 seconds
"Recommendations ships a bad model. Every call now takes 10 seconds. Nothing errors. Go."
Before you read on:
- With the baseline code, how long does
/hometake? What does the user see? - Using Little's law, how many home loads and how many recommendation calls are in flight on each pod?
- What does the screen look like after your fix, and what is the longest any user waits?
Answer
The arithmetic. Each pod gets 100 home loads/s, and each waits 10 s, so 1,000 home loads are open on every pod instead of about 20, each holding the four finished results in memory and a socket to the sidecar for recommendations. Across 30 pods that is 30,000 concurrent calls into a service that is already slow, which makes it slower. The app gives up at its own 10 s timeout and the user sees an error. Nothing has failed, and the product is down.
Detect. Home p50 jumping to 10 s; in-flight requests per pod; recommendations p50 at 10 s on the client side; event-loop delay and heap growing on the BFF.
Contain. Timeouts from the p99, and a deadline for the whole request.
const BUDGET = { profile: 80, posts: 150, recs: 250, ads: 120, badge: 50 };
app.get('/home', async (req, res) => {
const deadline = AbortSignal.timeout(400); // the whole screen
const call = (name, path) =>
get(path, AbortSignal.any([deadline, AbortSignal.timeout(BUDGET[name])]));
const u = req.user.id;
const [profile, posts, recs, ads, badge] = await Promise.allSettled([
call('profile', `/profile/${u}`), call('posts', `/posts/feed/${u}`),
call('recs', `/recs/${u}`), call('ads', `/ads/slot?u=${u}`),
call('badge', `/notifications/count/${u}`),
]);
if (posts.status === 'rejected') return res.status(503).json({ retryAfterMs: 2000 });
res.json({
profile: value(profile) ?? (await cachedProfile(u)),
posts: posts.value,
recs: value(recs) ?? (await lastGoodRecs(u)), // degraded, not broken
ads: value(ads), badge: value(badge),
});
});
const value = (r) => (r.status === 'fulfilled' ? r.value : undefined);
const get = (path, signal) =>
fetch(`http://localhost:15001${path}`, { signal }).then((r) => r.json());
allSettled instead of all, because one optional failure must not reject the whole screen. The per-call timeouts come from each service's p99 plus a margin; the 400 ms deadline caps the total. Now a home load holds recommendations for at most 250 ms: 100 × 0.25 = 25 calls in flight per pod.
Degrade. Decide in advance which sections are core (posts) and which are optional (recommendations, ads, badge), and what each optional one falls back to: the user's last good recommendations, written to Redis whenever a call succeeds (TTL 24 h), then a global "popular now" list kept in process memory and refreshed every 5 minutes. The screen shows up in about 260 ms with slightly older recommendations.
Recover. Recommendations rolls back the model. Nothing to do on our side: the next successful calls overwrite the last-good copies.
Prevent. Set an explicit connection cap so the in-flight count can't run away: one keep-alive agent to the sidecar with maxSockets: 256, about 13× the normal 20. Remember this cap: it comes back in Break 5. And pass the deadline downstream (a header such as x-deadline-ms with the time left), so recommendations can stop work nobody will wait for (Chapter 1).
The follow-up: "Your fallback reads the last-good recommendations from Redis. What if Redis is slow too?"
Answer
Then the fallback has become a second slow dependency, and the request waits for both. A fallback must be cheaper and more reliable than the thing it replaces: give the Redis read its own 20 ms timeout, and if that fails, use the in-process "popular now" list, which can't be slow because it is already in memory. Never chain remote fallbacks without a timeout on each one, and check the total against the deadline: 250 ms for recommendations plus 20 ms for Redis still fits in 400 ms.
Break 3: Now it fails half the time
"Recommendations is fast again, but half its pods are crash-looping. 50% of calls come back as
503in 20 ms. Do you keep calling it?"
Before you read on:
- What does the user see right now, with your Break 2 fallback?
- If you open a circuit breaker, what does the user get, and what does recommendations get?
- What would make opening the breaker the wrong call?
Answer
What the user sees now. Half the screens have live recommendations, half have last-good ones. With the Break 2 fallback this is already a degraded-but-working state, which is why the fallback comes before the breaker.
What kind of 50%? That decides it.
- Overload (crash-looping pods, so the survivors are swamped): errors are spread over all calls, and the 503s often carry
Retry-After. Here a breaker helps: while it's open, recommendations gets 30 pods' worth of load (3,000 calls/s) removed, which may be what lets its pods start. Users lose the live half of recommendations for a while, a small price. - A deterministic bug (say, every user with an empty history gets a 500): the same users fail every time, and the other half succeed every time. A breaker throws away the good half and helps nobody. Keep calling, fall back per request, and page the owning team.
Detect. Error rate per dependency, split by status code and by error type; the breaker's state as a metric and an alert when it opens.
Contain. One breaker per dependency (not one for everything): open when errors exceed 40% over a 10 s window with at least 100 calls, so a handful of errors at low traffic can't trip it; stay open 10 s; then half-open, letting 5 probe calls through, and close only if they succeed. The load balancer's outlier ejection works one level down: it removes individual bad pods, which in the crash-loop case is the better fix. Breakers are covered in depth in Q30 and Chapter 3.
Degrade. Last-good recommendations, as in Break 2.
Recover. When recommendations recovers, the half-open probes succeed and the breaker closes by itself. Watch for flapping (the timeline's last rows): if a breaker closes and reopens every 20 s, the errors are not overload, or the probe count is too small to tell.
Prevent. The fallback, the per-dependency breaker, and an alert on "breaker open for more than 5 minutes", which is a degraded product someone should know about.
The follow-up: "Why a 40% error-rate threshold with a minimum call count, rather than 'open after 5 consecutive failures'?"
Answer
At a 50% random error rate, five failures in a row happen with probability 0.5⁵ ≈ 3% per run of five calls; at 100 calls/s that's several times a second, so a consecutive-failure breaker opens almost at once and, after each half-open, opens again. At 10% errors, which may be normal background noise for some services, it still opens now and then (0.1⁵ is tiny per run, but there are millions of runs a day). A rate over a window measures what you actually care about, and the minimum call count stops it reacting to three errors at 4 am. Consecutive-failure breakers are fine for a dependency that is either fully up or fully down.
Break 4: The retries make it worse
"A teammate's fix for the 503s: retry recommendations 3 times, 100 ms apart. The mobile team also made the app retry
/hometwice on any error. And inside recommendations, every call to its feature store is retried 3 times. Recommendations was at 50% errors because it's overloaded. What happens now?"
Before you read on:
- Multiply it out: if every call fails, how many requests does recommendations get per home load? How many does the feature store get?
- Do the BFF's retries fit inside the 400 ms deadline?
- Where should retries live, and what limits them?
The sim shows why retries keep an outage going after the cause is gone: 40 clients call a provider that is down from 2 s to 6 s. Lock in a prediction, press Play with Retry set to Exponential, and watch the requests-per-tick chart against the capacity line after 6 s. Then switch Retry to Fixed 1 s and to Exp + jitter and compare Peak after recovery and Time to recover.
Answer
What the sim shows. With exponential backoff and no jitter, the clients that failed together stayed in lockstep: each retry wave landed in one 100 ms tick, up to 32 requests against a capacity of 8, and the provider had still not recovered by the end of the 24 s window. With fixed 1 s retries it sent 855 requests and also never recovered. With full jitter (sleep(random() × backoff)), the peak was 7 and it recovered 4.5 s after coming back.
The amplification here. Each layer multiplies the one below it. When calls fail, the app tries /home up to 1 + 2 = 3 times; the BFF tries recommendations 1 + 3 = 4 times per home load; recommendations tries the feature store 1 + 3 = 4 times per call.
| Layer | Normal | Everything failing |
|---|---|---|
| Home loads reaching the BFF | 3,000/s | 3,000 × 3 = 9,000/s |
| Calls to recommendations | 3,000/s | 9,000 × 4 = 36,000/s (12×) |
| Calls to the feature store | 3,000/s | 36,000 × 4 = 144,000/s (48×) |
A service at 50% errors because it's overloaded doesn't get better at 12× the load. It goes to 100% errors and stays there even after its pods are fixed, because the retries alone exceed its capacity. This is the retry storm: the outage now sustains itself.
And the BFF's retries can't even help. The first attempt times out at 250 ms; a retry 100 ms later starts at 350 ms and needs up to 200 ms more. It finishes after the 400 ms deadline, so its result is thrown away. These retries add load and nothing else.
Detect. Retry rate per client as its own metric (retries ÷ first attempts); a dependency's incoming rate rising while the user-facing rate is flat.
Contain. Turn off the BFF's retries to recommendations now, with a flag: it has a fallback, and for optional data a fallback is better than a retry. Tell the app to stop: return a 200 with a partial screen instead of a 5xx (an app doesn't retry a success), and honour Retry-After when you do fail.
Degrade. Last-good recommendations, as before.
Recover. Once the extra load stops, recommendations recovers on its own, which you can watch in the sim's jitter run.
Prevent.
- Retry at one layer only, the one that knows the deadline and the idempotency of the call. Here: the app retries
/homeonce on network errors; the BFF doesn't retry optional sections; recommendations retries the feature store once. - A retry budget: retries may be at most 10% of calls per client (a token bucket where each success adds 0.1 token and each retry costs 1). In the worst case each layer then multiplies by 1.1, so three layers give 1.1³ ≈ 1.33× instead of 48×.
- Exponential backoff with full jitter, and no retry when the remaining deadline is shorter than the call's p99.
The follow-up: "Your 10% budget works per process. You have 30 BFF pods you control and 2 million phones you don't. How do you budget the phones?"
Answer
You can't coordinate them, so you control what they see. Ship the app with jittered exponential backoff and a cap of one automatic retry. Then steer it from the server: return 200 with whatever sections you have, so most failures never look like failures to the app; send Retry-After on real errors and make the app obey it; and keep a server-driven switch (a field in the response, or remote config) that tells the app to stop retrying altogether during an incident. The phones' retries are the largest multiplier in the table above, and the only way to cut them during an incident is a switch you built beforehand.
Break 5: The shared connection pool runs out
"Recommendations has a new, better model. Its p99 is now 1.5 s, so product moved it into its own lazy-loaded call,
GET /home/recs, and you raised its timeout to 2 seconds. At 9 pm, traffic is 1.5× and recommendations slows to its timeout. Now/homefails completely, and so does/search, which never calls recommendations. Why?"
Before you read on:
- How many sockets does recommendations want per pod at 9 pm? Compare it with the 256-socket cap from Break 2.
- A
postscall or a/searchcall needs a socket and none is free. What happens to it? - What would you change so recommendations can only ever hurt recommendations?
Answer
The arithmetic. At 9 pm each pod sees 150 home loads/s, so 150 recommendation calls/s, each now allowed to take 2 s: 150 × 2 = 300 sockets wanted, against a cap of 256 that every call from every endpoint shares. Once recommendations holds all 256, a posts call, a profile call or a /search call waits in the agent's queue for a socket. Its own 150 ms timeout expires while it is still queued, before a byte is sent. /home loses its core section and returns 503; /search fails although it has nothing to do with recommendations. The 2 s timeout looked like a product decision about one feature; through Little's law it was a decision about the whole pod: raising the timeout from 250 ms to 2 s multiplied recommendations' worst-case socket use by 8.
Detect. Pool saturation per pod (sockets in use = cap, queued requests > 0); errors on endpoints that don't call the slow service, which is the signature of a shared resource; traces where spans spend their time before the request is even sent.
Contain. Cut recommendations' timeout back to 250 ms with a flag, or switch /home/recs to the fallback for everyone. Either frees the sockets within seconds.
Degrade. Last-good recommendations in the lazy-loaded section; everything else is back to normal.
Recover. Restore the 2 s timeout only after the bulkhead below is in place.
Prevent. Bulkheads: give each downstream its own pool, sized from its own Little's law at the 9 pm peak, so one can't take another's sockets (Chapter 3). Separate Agent objects keep separate pools even to the same sidecar address.
| Downstream | Peak rate per pod × timeout | Sockets needed | Pool |
|---|---|---|---|
| posts | 150 × 0.15 s | 23 | 40 |
| profile | 150 × 0.08 s | 12 | 20 |
| ads | 150 × 0.12 s | 18 | 30 |
| notifications | 150 × 0.05 s | 8 | 15 |
| recommendations | 150 × 2 s | 300 | 60, and full means fall back at once |
/search dependencies | own budget | its own pool |
Recommendations' pool is deliberately smaller than its worst case: when it is slow, 60 sockets serve 30 calls/s per pod and the other 120 get the fallback immediately instead of queueing. Put a semaphore in front of it that rejects when 60 calls are in flight, so the rejection takes microseconds instead of a 2 s wait. The total, 165 sockets, is below the old shared 256, and no service can starve another.
The follow-up: "Why not just raise maxSockets to 2,000?"
Answer
Because the pool was also protecting recommendations. With 2,000 sockets each pod would hold 300 calls open, 9,000 across the fleet, against a service that is slow because it's overloaded: more open calls make it slower, which makes each call hold its socket longer. You'd also move the limit somewhere you can't see: the sidecar has its own per-upstream limits (Envoy, for example, defaults to 1,024), and the process has file-descriptor limits. A bigger shared pool delays the exhaustion; separate pools stop it spreading. The fix is isolation plus a limit that rejects fast, not a bigger number.
The final design
| Change | Why | Break |
|---|---|---|
| Least-requests or power-of-two load balancing, outlier ejection | One slow instance stops collecting a quarter of the traffic | 1 |
| Hedged requests for recommendations at 150 ms, capped at 10% | Cuts tail latency from one slow instance | 1 |
| Per-call timeouts from each p99, a 400 ms deadline for the screen | No dependency can hold a request longer than its budget | 2 |
Promise.allSettled, core vs optional sections | One optional failure can't fail the screen | 2 |
| Fallbacks: last-good recommendations (20 ms timeout), popular list in memory | A degraded screen instead of an error | 2, 3, 4, 5 |
| Deadline passed downstream in a header | Downstreams stop work nobody will wait for | 2 |
| One circuit breaker per dependency, error rate over a window with a minimum count | Shed load from an overloaded dependency without flapping | 3 |
| No BFF retries for optional sections; one retry layer; 10% retry budget; full jitter | Retries can't turn a partial outage into a total one | 4 |
200 with a partial screen, Retry-After, a server-driven retry switch for the app | The phones stop multiplying the load | 4 |
| A separate pool per downstream, sized by Little's law at peak | One slow service can't take another's sockets | 5 |
| A semaphore that rejects recommendations calls when its pool is full | Fail in microseconds, not after a 2 s wait | 5 |
Scoring yourself
| Skill | Strong | OK | Weak |
|---|---|---|---|
| Little's law | Computes in-flight counts per pod (20 normal, 1,000 at 10 s, 300 sockets at 9 pm) and uses them to size things | Says "requests pile up" | Talks about latency only, never concurrency |
| Tail latency | Explains why one slow instance makes p99 climb without bound, proposes least-requests, ejection, hedging | Adds a timeout | Expects a health check to catch it |
| Timeouts and deadlines | Per-call timeouts from p99, one deadline for the request, passed downstream | Adds a timeout with a number | "Set a timeout" with no value, or 30 s |
| Degradation | Decides core vs optional sections in advance, with layered fallbacks that have their own timeouts | Shows the screen without recommendations | Returns a 500 when any call fails |
| Circuit breakers | Asks what kind of 50%, uses error rate with a minimum count, half-open probes, knows when not to open | Adds a breaker | Opens on any error |
| Retries | Multiplies the layers (3 × 4 × 4), knows retries can't fit the deadline, one layer plus a budget plus jitter | Adds backoff | Adds retries as the fix |
| Bulkheads | Finds the shared pool from "unrelated endpoints fail", sizes separate pools, rejects fast when full | Separates pools without sizing them | Raises maxSockets |
Strong on five or more rows is a strong signal. The question that separates candidates most often is Break 5's "why does /search fail?": a strong candidate goes looking for the resource the two endpoints share.
What's next
Drill 12 — The Queue Backs Up: the slow downstream is an email provider, and between you and it there is a queue holding 2 million messages.