Part III · Chapter 3 · Under Load · lesson 5
Your median is 20ms and your p99 is 2 seconds
14 min
Builds on Why it gets slow at 70% busy, not at 100%
Part III · Chapter 3 · Under Load · lesson 5
14 min
Builds on Why it gets slow at 70% busy, not at 100%
The search endpoint has a median of 20ms and a p99 of 2 seconds. Every one of the twenty shards it queries reports a healthy p99 of 100ms. Nobody can find the slow shard, because there isn't one: the shards take turns.
Two separate things are happening here, and the usual explanation covers only one of them. Commit to a number before you read on.
Predict
Fan-out amplifies tail latency in two different currencies, and they move at different speeds.
The latency figure grows logarithmically. The maximum of n independent draws from a distribution with an exponential tail grows like log n. Twenty calls cost you 1.65 times the per-call tail; two hundred calls cost 2.15 times it. Ten times the fan-out for a third more latency.
The frequency grows almost linearly. The chance that at least one call is slow is 1 − 0.99 to the power n. At 20 calls that is 18%. At 69 calls it is an even bet — half of all requests contain a call from the slowest 1%. This is the one that bites, and it is the one no per-shard dashboard shows, because every shard is behaving exactly as advertised.
So: your p99 gets somewhat worse, and your p80 gets dramatically worse.
Slide the fan-out and watch the two lines. The latency line bends over. The other one does not.
Tail amplification
Hypothetical model, not a forecast
What a fan-out does to a tail latency when one user request waits on many backend calls at once.
It assumes
It ignores
Raise the fan-out until it is an even bet that some call in the request lands in its own slowest 1% — the point where half of all requests contain a slow call.
The latency the stated percentile of single calls comes in under.
| Backend calls per request | Tail the user sees | Tail of one call |
|---|---|---|
| 1 | 100 ms | 100 ms |
| 2 | 115 ms | 100 ms |
| 3 | 124 ms | 100 ms |
| 5 | 135 ms | 100 ms |
| 8 | 145 ms | 100 ms |
| 12 | 154 ms | 100 ms |
| 20 | 165 ms | 100 ms |
Not there yet — keep moving the controls.
69 calls. Which is a routine fan-out — a feed page assembling items, a permissions check per object, a dashboard with that many tiles. At that point the median request contains a slow call, and the per-shard p99 is still a perfectly honest 100ms.
Not from here. A hundredfold gap between median and p99 needs a mechanism this model explicitly leaves out, and there are two candidates worth checking in order.
Queueing, from the first lesson in this track. Response time is service time over the idle share, and at 99% utilisation that multiplier is 100. A gap of exactly the size in the opening paragraph is what a saturated queue produces, and it produces it on the requests unlucky enough to arrive while the queue is deep. Check utilisation and queue depth at the moment of the slow requests, not on average.
Correlated slowness, which is strictly worse than independence. The model assumes twenty independent draws. If all twenty shards share a connection pool, a node, a disk, or a garbage collector, then they are slow together — and the maximum of twenty correlated draws is just one draw, except it happens to every request at once. This is the failure mode where a per-shard p99 stays beautiful and the user-visible p99 collapses, because the slow periods line up.
The diagnostic that separates them: take the slow requests and ask whether their slow calls were spread across shards or concentrated in time. Spread across shards and spread in time is the independent case in this model. Concentrated in time is a shared resource, and the shared resource is what you fix.
Hedged requests. Send the call, and if it has not answered by roughly the
p95, send a second copy to a different replica and take whichever returns first.
This is the single most effective fix for an independent tail, because it
converts "wait for the slowest" into "wait for the faster of two attempts", and
it only costs extra load on the small fraction of calls that were going to be
slow anyway. Envoy exposes it as hedge_on_per_try_timeout; gRPC has a
hedgingPolicy in its service config.
The tradeoff is the thing that makes hedging dangerous: it adds load precisely
when the system is already struggling, and a correlated slowdown makes every
call eligible at once. So hedge only behind a budget — Envoy's retry_budget
caps retries and hedges as a percentage of active requests, and gRPC's retry
throttling does the same. Hedging without a cap is a retry storm with a
respectable name.
Don't wait for all of them. The other bound, and the one that needs a product decision rather than a configuration change. Set a per-call deadline well inside the request's own budget, render what came back, and mark the rest as missing. A feed with nineteen of twenty items is a feed; a feed that waits is a spinner. Google's own write-up of this calls it returning "good enough" results, and the hard part is never the code — it is agreeing which parts of the response are optional.
What I would actually reach for, given one afternoon: per-call deadlines first. They are a one-line change per call site, they bound the damage immediately, and they cost no extra load. Hedging second, behind a budget, for the calls that are genuinely idempotent reads. And then the thing that beats both, which is reducing the fan-out — batching twenty per-object permission checks into one call takes the amplification from 1.65 to 1 and removes the frequency problem entirely.
Check
Everything above assumes the request needs every answer. Flip that and the arithmetic flips with it.
If the request needs only the first answer — a read from any of three replicas, a lookup against any cache node that has it — then you are taking the minimum of n draws rather than the maximum, and more calls make the tail better. The chance that all three replicas are simultaneously slow is 0.01 cubed, which is a millionth. This is why quorum reads and replica fan-out improve tail latency while shard fan-out destroys it, and the two look identical on an architecture diagram.
The question to ask of every fan-out on your diagram is therefore not "how wide is it" but "how many of these answers does the request need". All of them is the case in this lesson. Any one of them is the opposite case, and it is free insurance. Some of them — a quorum — sits in between and is usually much closer to the good end.
Take a distribution whose tail falls off exponentially: the chance of exceeding t is exp(−t/θ). The chance that all n independent calls come in under L is then (1 − exp(−L/θ)) to the power n, and setting that equal to q and solving gives L = −θ × ln(1 − q^(1/n)).
For large n, q^(1/n) approaches 1, and the expression behaves like θ × ln(n) plus a constant. Which is the result: each doubling of the fan-out adds θ × ln 2 to the tail — a fixed amount, not a multiple.
Two consequences worth carrying. First, a fan-out of 1000 is not fifty times worse than a fan-out of 20; it is about twice as bad. Second, the only way to move the whole curve down is to change θ, which is the per-call distribution itself. Reducing the fan-out slides you leftward along a curve that is already flat; reducing per-call variance moves the curve.
That is the quiet argument for spending effort on the slowest shard's behaviour rather than on the fan-out's width — right up until you remember the frequency term, which does scale with n and does not care about θ at all.