Part III · Chapter 3 · Under Load · lesson 3
How many requests can be inside your service at once
13 min
Builds on Why it gets slow at 70% busy, not at 100%
Part III · Chapter 3 · Under Load · lesson 3
13 min
Builds on Why it gets slow at 70% busy, not at 100%
Somebody sized the worker pool at 16 because the box has 16 cores. The service handles each request in 50ms, of which 40 is a Postgres round trip. Traffic is now asking for 400 requests a second and the completions have flattened at a number well below that, with a growing queue in front of nginx.
The pool size and the throughput ceiling are the same fact stated twice. Here is the conversion.
Predict
Little's law. The average number of requests inside a system equals the arrival rate times the average time each one spends there.
L = λ × W
That is all of it. It is an identity rather than a model: it assumes only that the system is in steady state, with as many requests leaving as arriving. No distribution, no independence, no Poisson arrivals. It holds for your service and it held yesterday.
Name any two and the third is decided. Which is why it answers three different questions with one division:
Below is the identity with the pool held at 16. The rising line is the concurrency the traffic demands; the flat line is the concurrency you have. They cross at the ceiling.
Little's law
Hypothetical model, not a forecast
Ties arrival rate, latency and concurrency together, solving for whichever of the three is left implied.
It assumes
It ignores
Today's peak is 400 a second. Hold the pool at 16 and raise the latency until this pool cannot carry even 150 a second — then read off how much latency it took.
Average time one request spends in the system, entry to exit.
| Arrivals a second | In flight, demanded | In flight, available |
|---|---|---|
| 32 | 1.6 | 16 |
| 64 | 3.2 | 16 |
| 96 | 4.8 | 16 |
| 128 | 6.4 | 16 |
| 160 | 8 | 16 |
| 192 | 9.6 | 16 |
| 224 | 11.2 | 16 |
| 256 | 12.8 | 16 |
| 288 | 14.4 | 16 |
| 320 | 16 | 16 |
| 352 | 17.6 | 16 |
| 384 | 19.2 | 16 |
| 416 | 20.8 | 16 |
| 448 | 22.4 | 16 |
| 480 | 24 | 16 |
| 512 | 25.6 | 16 |
| 544 | 27.2 | 16 |
| 576 | 28.8 | 16 |
| 608 | 30.4 | 16 |
| 640 | 32 | 16 |
Not there yet — keep moving the controls.
It takes about 107ms. A slightly over two-fold rise in latency cuts the service's throughput ceiling by slightly over half, from 320 a second to under 150 — and the latency rise does not have to be yours. A Postgres primary with a fuller buffer cache, an index that stopped being used, a replica promoted onto a smaller instance: any of those lengthens the hold time, and the hold time is the divisor.
This is the mechanism behind "it worked at 100 and fell over at 400". The pool did not change. The hold time did, and the ceiling moved with it.
Three numbers and one multiplication:
For 400 a second at a p95 hold of 60ms: 400 × 0.06 = 24 slots, times 1.5 is 36.
So gunicorn --workers 4 --threads 12 across three pods, or 12 workers of 3
threads — the split matters for memory and for the GIL, not for this number.
And then the part people skip: the pool has to be the smallest of the pools.
Thirty-six application slots each wanting a database connection, against
pgbouncer's default_pool_size of 20, means the real concurrency limit is 20
and you have built a queue between two of your own components. Size inwards:
the database's concurrency limit first, then the pool in front of it, then the
worker slots in front of that.
A bigger pool. The first move and often correct, up to the point where the pool stops being the constraint. Past that point every added slot is a request admitted into a longer queue, which raises its hold time, which eats the capacity the extra slot was supposed to buy. HikariCP's own guidance has argued for years that small pools outperform large ones against a relational database, and the arithmetic reason is this one: a database asked to run more CPU-bound statements at once than it has cores does not finish them any sooner, so the extra sessions only lengthen W. The caveat is the workload — statements that spend most of their time waiting on storage do benefit from more concurrency than the core count, which is why the figure to size against is the database's own measured concurrency at saturation rather than its core count.
A connection-pool timeout. This is the setting I would not ship without.
HikariCP's connectionTimeout, or SQLALCHEMY_POOL_TIMEOUT on the Python side,
bounds how long a request waits for a slot before failing. Without it, a
saturated pool is an unbounded queue, and an unbounded queue is the slow version
of an outage. With it, you shed the overflow and the requests that do get
through keep their latency. Set it well below the caller's own timeout —
otherwise the caller gives up first and you do the work for nobody.
Async, to make the slots cheap. Switching from sync workers to asyncio or virtual threads genuinely removes the worker-slot ceiling: ten thousand coroutines waiting on sockets cost almost nothing. It does not remove the database pool's ceiling, and it makes the queue in front of that pool enormous rather than visible. Async without an explicit concurrency limit converts "we run out of workers at 320 a second" into "everything times out at 320 a second", which is harder to diagnose and worse for the user.
An explicit in-flight limit. The honest version of all of the above: pick a
number from L = λ × W, enforce it at admission, and return 503 past it. Envoy
does this through a circuit-breaker max_requests; a Go service does it with a
buffered channel; nginx does a cruder version with limit_conn. The value of
making it explicit is that the ceiling becomes a number in a file that somebody
chose, instead of the product of two unrelated settings nobody connected.
Check
Little's law needs steady state: completions keeping pace with arrivals over the averaging window. During the thing you are most likely to be looking at, it is not in steady state at all.
In an overload, arrivals exceed completions and the backlog grows. In that regime L is not λ × W because W is not finite yet — it is still increasing, and any value you measure for it is a reading of a transient. The same applies in the first seconds after a deploy, during a cold-cache period, and while a queue drains.
The practical rule: average over a window long enough that arrivals and completions match within a few per cent, and treat any window where they do not as a measurement of the overload rather than of the service. If the backlog is growing, the number you want is not a pool size — it is a shedding threshold, which is a different lesson.
The margin advice above leans on a figure worth justifying, because it decides the pool size.
A consumer product does most of a day's traffic in about four waking hours. 24 divided by 4 is 6, so peak-minute traffic lands near six times the flat daily average. An internal tool used in one timezone's working day is closer to three. A service driven by cron jobs on the hour is far worse than either, because its traffic is not spread over hours at all — it arrives in the first few seconds of each minute, and the relevant multiple is the ratio of a minute to those seconds.
Which means: derive the multiple from your own request-count metric at its finest available resolution, and be suspicious of a one-minute average if your clients share a clock. A pool sized against a one-minute average when the traffic arrives in five-second bursts is undersized by whatever that ratio is.