Part IV · Chapter 7 · When It Breaks · lesson 4
Under overload you refuse work, or you lose all of it
14 min
Builds on Why it gets slow at 70% busy, not at 100% and The queue has 400,000 messages and is growing
Part IV · Chapter 7 · When It Breaks · lesson 4
14 min
Builds on Why it gets slow at 70% busy, not at 100% and The queue has 400,000 messages and is growing
A promotion goes out at 10:00 and arrivals roughly double. The checkout service accepts every request, as it always has. By 10:06 its p99 is over thirty seconds, every worker is busy, CPU is pinned, and the queue holds about nine thousand requests.
Successful checkouts in that minute: eleven. The mobile client gives up after ten seconds, so almost everything the service produces is a response to somebody who left.
The service is at full utilisation and delivering nothing.
Predict
An unbounded queue does not absorb overload. It converts a fast failure into a slow one and charges you for the difference.
Below capacity, a queue is a buffer: the wait settles at some finite value and bursts get smoothed. Above capacity, arrivals exceed departures by a fixed amount every second and the backlog grows without limit. The wait grows with it.
Past the point where the wait exceeds the caller's timeout, every unit of work the system completes is delivered to a caller who has gone. Capacity is fully consumed and goodput is zero. The queue also holds memory, so a long enough overload ends in an out-of-memory kill rather than a recovery.
Refusing work is not a degraded mode. It is the only mode in which an overloaded system produces anything at all.
The threshold is not a round number somebody picked. It falls out of the wait you are willing to tolerate and the time one request takes.
Queueing delay
Hypothetical model, not a forecast
How long work waits in a single-server queue as utilisation rises, and the utilisation at which that wait breaks a stated budget.
It assumes
It ignores
Leave the service time where it is. Raise the arrival rate until the model stops reporting a longer wait and starts reporting that the offered load is at or above capacity — the state where the number on the screen is a clamp rather than a forecast.
Requests reaching the server each second.
The queueing wait that counts as too long. Service time is not part of it.
| Utilisation, per cent | Waiting before service starts | Total time in the system |
|---|---|---|
| 1 | 202 us | 20 ms |
| 2 | 408 us | 20 ms |
| 5 | 1.1 ms | 21 ms |
| 10 | 2.2 ms | 22 ms |
| 20 | 5 ms | 25 ms |
| 30 | 8.6 ms | 29 ms |
| 40 | 13 ms | 33 ms |
| 50 | 20 ms | 40 ms |
| 60 | 30 ms | 50 ms |
| 70 | 47 ms | 67 ms |
| 80 | 80 ms | 100 ms |
| 85 | 113 ms | 133 ms |
| 90 | 180 ms | 200 ms |
| 92 | 230 ms | 250 ms |
| 94 | 313 ms | 333 ms |
| 95 | 380 ms | 400 ms |
| 96 | 480 ms | 500 ms |
| 97 | 647 ms | 667 ms |
| 98 | 980 ms | 1,000 ms |
| 99 | 1.98 s | 2 s |
Not there yet — keep moving the controls.
Three numbers to take away from that.
At forty-five arrivals a second against a twenty-millisecond service time, utilisation is 90 per cent and the queueing wait is about 180 milliseconds — nine times the service time, for a server that is idle a tenth of the time. Utilisation of 90 per cent is not 10 per cent of headroom; it is a tenfold latency multiplier.
The wait budget of 100 milliseconds breaks at 83.33 per cent utilisation. That is the threshold, and it is a closed form: the budget divided by the budget plus the service time. In arrivals it is about forty-one a second, which is the number to put in the admission limit.
And above fifty arrivals a second the model flags overload instead of quoting a wait, because there is no wait to quote. Nothing settles. The figure on the screen is a clamp the model applies so the arithmetic stays finite, and the honest answer is that the queue grows for as long as the overload lasts.
Bound the queue by time, not by length. A length limit has to be re-tuned
every time the service time changes, and it is the wrong unit: what you care
about is whether a request has been waiting longer than its caller will wait.
The mechanism to copy is CoDel, adapted for request queues in Meta's fbthrift
— it tracks the minimum time a request has spent in the queue over a sliding
interval and starts dropping when that minimum stays above a small target,
which detects a standing queue while ignoring short bursts. Pair it with LIFO
service under overload: when the queue is deep, the newest request is the one
most likely to still have a caller attached.
Put a concurrency limit in front, and measure it rather than guessing.
Netflix's concurrency-limits library derives the limit from observed latency
using gradient-based algorithms, which is Little's law run backwards: the limit
that keeps latency at your target is the target latency times the throughput you
can sustain. A measured adaptive limit survives a change in service time; a
hand-set number does not.
Use what your edge already has. nginx's limit_req is a leaky bucket with a
burst allowance and nodelay to reject rather than delay; limit_conn caps
concurrency per key. Envoy's circuit breakers expose max_connections,
max_pending_requests and max_requests per cluster, and setting
max_pending_requests low is exactly a bounded queue with fast rejection.
Kubernetes' own API server ships API Priority and Fairness, which is the full
pattern as a worked example: named priority levels with their own concurrency
shares and queue sets, flow schemas assigning requests to them, and a 429 with
a Retry-After when a level is full.
Declare criticality, and shed the bottom tier first. The scheme described in
Google's SRE book — four levels from CRITICAL_PLUS down to SHEDDABLE — works
because the tier travels with the request and every hop honours it, so shedding
can happen at whichever layer notices first. The important half is that the tier
is set by the caller and verified at the boundary, otherwise everything arrives
labelled critical within a quarter.
If you are adding one thing to a service that currently has nothing: a
concurrency semaphore sized from your own measured latency target, in front of
the handler, returning 429 with Retry-After when it cannot acquire. It is
perhaps twenty lines. Everything above is a better version of it.
What would you do
Check
Every mechanism above buys time. They assume the overload is a burst that an admission limit can hold the line through until it passes, or until capacity arrives.
If the overload is permanent — a tenfold traffic increase that is the new baseline, a dependency that has become four times more expensive to call, an abuse pattern that scales as fast as you shed it — then no queue bound and no concurrency limit changes the outcome. Shedding half of a doubled load serves your old traffic; shedding ninety per cent of a tenfold load serves your old traffic and refuses nine of every ten customers indefinitely, which is a business failure wearing an availability number.
The crossing to watch for is the duration of the overload against the time it takes you to add real capacity. While overload is shorter, admission control is the answer and this lesson holds. Once overload consistently outlasts your provisioning time, admission control has stopped being a fix and become a way of reporting a capacity decision nobody has made yet — and the work moves to the cost per request and the number of machines, which is a different track.