Part III · Chapter 3 · Under Load · lesson 1
Why it gets slow at 70% busy, not at 100%
14 min
Builds on Will it fit on one box?
Part III · Chapter 3 · Under Load · lesson 1
14 min
Builds on Will it fit on one box?
Your service runs as eight pods, one single-threaded process each, behind a round-robin load balancer. Each handles one request at a time and takes 20ms, so the arithmetic ceiling is 400 requests a second. At 100 a second nobody complains. At 280 a second the on-call channel opens. The CPU graph reads 70% and looks like headroom, because a graph with 30% of its height unused looks like headroom.
It is not headroom. The number that would have told you lives in a formula with one division in it.
Predict
A single server taking work from a queue, with requests arriving at random times, has a closed-form answer. Two lines of it:
That divisor is the whole lesson. At 70% busy you are dividing by 0.3. At 90% you are dividing by 0.1. Going from 70% to 90% does not add 20 points of load, it removes two thirds of the idle time the queue was draining into.
Utilisation is not a measure of how much room you have left. It is a measure of how much of your slack you have already spent.
The chart below is one pod, with its own queue, taking an eighth of the traffic. Slide the arrival rate and watch where the line leaves the floor — it is much earlier than the shape of a CPU graph suggests.
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
Raise the arrivals until one worker's average response time passes 100ms — five times the work it is actually doing. Read off the utilisation, then multiply the arrival rate by eight to get the whole service's figure.
Requests reaching the server each second.
How long one request occupies the server when nothing is queued.
| Utilisation, percentage points | Time in the system | Time spent waiting |
|---|---|---|
| 1 | 20 ms | 202 us |
| 2 | 20 ms | 408 us |
| 5 | 21 ms | 1.1 ms |
| 10 | 22 ms | 2.2 ms |
| 20 | 25 ms | 5 ms |
| 30 | 29 ms | 8.6 ms |
| 40 | 33 ms | 13 ms |
| 50 | 40 ms | 20 ms |
| 60 | 50 ms | 30 ms |
| 70 | 67 ms | 47 ms |
| 80 | 100 ms | 80 ms |
| 85 | 133 ms | 113 ms |
| 90 | 200 ms | 180 ms |
| 92 | 250 ms | 230 ms |
| 94 | 333 ms | 313 ms |
| 95 | 400 ms | 380 ms |
| 96 | 500 ms | 480 ms |
| 97 | 667 ms | 647 ms |
| 98 | 1,000 ms | 980 ms |
| 99 | 2 s | 1.98 s |
Not there yet — keep moving the controls.
It happens at 40 arrivals a second — 80% of one pod, 320 requests a second across all eight. Not 400. The hardware's ceiling is 400 and this arrangement's useful ceiling is around 320, and the gap is not waste or inefficiency. It is the price of arrivals that do not politely space themselves out, paid eight times over because each pod queues alone.
The model also reports the utilisation at which the wait alone breaks a stated budget. At a 100ms budget and 20ms of work that is 83.3%, solved rather than searched: a budget B is broken at B / (B + S).
Autoscale on CPU. The default in Kubernetes, and as of this writing the
Horizontal Pod Autoscaler's averageUtilization target is what nearly everyone
sets. It works, late. The scale-up decision needs a metrics window, then a pod
schedule, then a container start, then a readiness probe to pass — and the queue
has been building through all of it. Setting the target to 70 does not give you
30% of headroom; it gives you a trigger that fires when response time has
already tripled.
What I would actually do: set the HPA target at 50–60% rather than 70, and treat it as a cost decision rather than a performance one. The honest reason to run at 60% is that the scaling loop is slow and the queueing curve is not.
Alert on the queue, not the CPU. The metric you want is requests waiting,
or time-in-queue, and most stacks already expose it: nginx's $request_time
minus $upstream_response_time is the time the request spent waiting to be
handed on; Puma reports backlog through Puma.stats; a Go service can export
the length of its own semaphore channel. One number, measured where the queue
actually forms, beats three dashboards of utilisation.
Share one queue across the workers. The highest-value change available here, and it is free. The eight pods above each queue alone, so each behaves like a single server and pays the full M/M/1 penalty. Eight workers drawing from one queue at the same 70% utilisation wait about a twentieth as long, because a request only waits when all eight are busy rather than when the one it was assigned to is.
Concretely: gunicorn's worker processes share one listening socket and therefore
one kernel accept queue, which is the good case. Eight pods behind a round-robin
balancer is the bad case, and it is the more common deployment. If you cannot
consolidate, a least-outstanding-requests policy at the balancer recovers most
of the benefit — Envoy's LEAST_REQUEST, or nginx's least_conn — because it
approximates one queue by always picking the shortest.
Bound the queue. The model above assumes an unbounded queue and nothing giving up, which is why it goes to infinity. A real service should refuse work instead: a concurrency limit with a short wait and a 503 past it. Track 5 is about choosing that threshold; what matters here is that an unbounded queue converts a latency problem into an outage with extra steps.
Check
The model assumes arrivals at random, independent times. That is the pessimistic end of a range, and your traffic may sit at either end of it.
If arrivals are evenly spaced, the curve is roughly half as bad — the constant-service-time, constant-arrival case has no variability to queue behind, and a perfectly regular queue has no wait at all until utilisation reaches 1. This is not a hypothetical: a batch consumer pulling from a queue at a fixed rate, or a load generator in closed-loop mode, behaves this way. Which is exactly why a load test can show a clean curve that production never reproduces.
If arrivals are bursty or correlated — retries, a cron job in every client firing on the minute, a mobile app that reconnects everyone after a network blip — the wait is worse than this at every utilisation, and the knee moves left.
So: use the formula to decide you cannot run at 90%. Do not use it to promise a particular millisecond figure at 70%. The shape is robust; the constant is not.
With one server the response time is S / (1 − ρ). With c servers sharing one queue it is an uglier expression (Erlang C), but the behaviour is easy to state: the knee moves right and gets sharper.
At 70% utilisation, one server waits 2.33 times its service time. Eight servers at 70% wait about 0.1 times it — a twentieth of the delay for the same utilisation. The reason is pooling: with eight servers, the chance that all of them are busy when a request arrives is much lower than the chance that one is.
The catch is at the other end. Those eight servers hold out to a much higher utilisation, and then fall over faster when they do go, because there is less and less statistical slack left to absorb a burst. A large pool run at 95% is a quieter, more dangerous place than a small pool run at 95%.
And all of it depends on one queue feeding all eight. Eight separate queues, which is what you get when a load balancer assigns requests to instances without knowing what each is doing, behave like eight single servers rather than one pool of eight.