Applying Little’s Law to Connection Pool Sizing
This guide is part of Connection Pool Sizing Formulas. Little’s Law is the only sizing tool that produces a number from measurement rather than from intuition, and it takes two values that most services already emit. It is also easy to apply wrongly in two specific ways, each of which changes the answer by roughly an order of magnitude — which is why services that “used the formula” so often end up badly sized anyway.
The failure it prevents looks like this: a pool sized at 50 that queues continuously at 300 requests per second, alongside a database sitting at 20% CPU. Nothing in the pool metrics explains it, because the pool is doing exactly what it was configured to do.
HikariPool-1 - Connection is not available, request timed out after 3000ms.
(total=50, active=50, idle=0, waiting=61)
# meanwhile:
postgres=# SELECT count(*) FROM pg_stat_activity WHERE state = 'active';
count
-------
4
Fifty connections checked out and four actually executing is the signature of a hold-time measurement that counted the wrong interval. This guide fixes that.
Rapid Incident Diagnosis
Three readings, in order, establish whether Little’s Law will help and what inputs to use.
- Pool checkout rate. Not request rate — the number of borrows per second, which is requests multiplied by queries per request. Most pools expose this as a counter; if not, the database’s own statement rate for this service is a close proxy.
- Hold time distribution. The interval between checkout and check-in. HikariCP publishes it as
hikaricp.connections.usage; SQLAlchemy needs the checkout/checkin event pair; Go requires instrumenting arounddb.Conn(). This is the number that goes wrong. - Backend state on the database.
SELECT state, count(*) FROM pg_stat_activity WHERE application_name = 'your-service' GROUP BY state. If checked-out connections greatly exceedactivebackends, the connections are held rather than working, and no sizing formula applies until that is fixed.
The decision rule: if active backends roughly match checked-out connections, Little’s Law describes your system and the derivation below applies. If they diverge sharply, hold time is being spent outside the database and the correct action is to reduce it, not to compute a larger pool.
The Formula and Its Two Inputs
L = λ × W
L concurrent connections in use (the floor for the pool)
λ borrows per second (not requests per second)
W mean hold time in seconds (not query duration)
Both notes in parentheses are where the derivation goes wrong.
λ is borrows, not requests. A request that issues four queries produces four borrows on a per-statement-checkout framework and one on a per-request one. Using request rate on a per-statement framework understates λ by the queries-per-request factor. The reliable source is the pool’s own checkout counter; the database’s statement rate for this service is a good second choice.
W is hold time, not query duration. The gap between them is everything the application does while holding the connection — result-set materialisation, object mapping, and any business logic inside the transaction. On a per-request-checkout framework that gap is most of the request. Measuring it requires instrumenting the checkout and check-in events rather than reading query latency from the database.
With both measured correctly, add headroom for variance. A 20–30% allowance covers ordinary jitter; anything more suggests the distribution is heavy-tailed enough that the acquisition timeout, rather than the pool size, should absorb the tail.
pool_floor = ceil(λ × W × 1.25)
Worked Example
A checkout service on a per-request-checkout framework, measured over a busy hour:
| Measurement | Value | Source |
|---|---|---|
| Requests per second, fleet-wide | 420 | Load balancer |
| Queries per request, mean | 3.2 | Pool checkout counter ÷ request counter |
| λ — borrows per second | 1,344 | The product |
| Hold time, mean | 11 ms | Checkout-to-check-in interval |
| Hold time, p99 | 180 ms | Same histogram |
| Query duration, mean | 3 ms | Database statement statistics |
L = 1344 × 0.011 = 14.8 concurrent connections. With 25% headroom, an aggregate floor of 19.
Had query duration been used instead of hold time, the result would have been 1344 × 0.003 = 4.0, and with headroom, 5 — a pool that would queue continuously while appearing to follow the formula. That single substitution is the most common way this derivation fails.
The p99 of 180 ms is worth a moment. Sizing for it would give 1344 × 0.18 = 242 connections, which is absurd — it assumes every borrow simultaneously takes the worst-case time. The correct treatment is to size for the mean and let the acquisition timeout absorb the occasions when several slow borrows coincide, with a circuit breaker behind it for the case where they persist.
Converting the Floor to a Configuration Value
The 19 above is an aggregate figure across the whole fleet. What gets configured is per process:
per_process = ceil(aggregate_floor ÷ (max_replicas_at_surge × workers_per_replica))
With 8 replicas at maximum, 25% surge, and 2 workers each, the divisor is 10 × 2 = 20, giving a per-process floor of 1. That is uncomfortably small, and the discomfort is informative: twenty processes sharing an aggregate requirement of 19 connections means each has essentially no headroom for a local burst.
The options at that point are to reduce the process count — fewer, larger processes with more threads — or to accept that individual processes will queue briefly and rely on the acquisition timeout. What is not an option is configuring each process for the aggregate figure, which multiplies the requirement twentyfold.
Validation and Verification
Three checks confirm the derived value is right, and all three are available within minutes of deploying it.
Pending waiters return to zero. The definitive check. If hikaricp.connections.pending, SQLAlchemy’s queue depth, or Go’s WaitCount rate stays at zero through a peak, the floor was adequate. Any sustained value means λ or W was underestimated.
Acquisition wait p99 stays well inside the timeout. A p99 above roughly 10% of the acquisition timeout means the pool is close enough to its limit that a modest traffic increase will start queueing.
Active backends approximately match checked-out connections. From pg_stat_activity. If checked-out connections substantially exceed active backends, hold time is being spent outside the database and the formula’s input was measured on a system that should be changed rather than sized around.
-- Run during peak: checked-out connections should be doing database work.
SELECT state, count(*)
FROM pg_stat_activity
WHERE application_name = 'checkout-service' AND backend_type = 'client backend'
GROUP BY state;
-- active: 14, idle: 5 → the derivation holds
-- active: 3, idle in transaction: 16 → hold time is not database time
Frequently Asked Questions
What if hold time is not instrumented and cannot easily be?
Does the law still hold when the system is saturated?
λ × W the system cannot be stable, and above it stability depends on the database keeping up.Should λ be peak or average?
How does this interact with the acquisition timeout?
Why not just use the p99 hold time and be safe?
Does the law apply to a proxy’s server pool as well?
default_pool_size should be derived from rather than guessed.What changes when several services share one database?
Related
- Connection Pool Sizing Formulas — the parent guide covering all three constraints on pool size.
- Sizing Connection Pools for Kubernetes Horizontal Pod Autoscaling — getting the divisor right when the process count moves.
- Optimizing HikariCP maximumPoolSize for High Concurrency — applying the result on the JVM.
- Measuring Connection Acquisition Latency Percentiles — instrumenting the wait side of the same system.