Backend Systems · Service Reliability
Concurrency Limits Derived from Little's Law
How the relation between in-flight requests, arrival rate, and latency produces a concurrency limit, and why an adaptive limit tracks a dependency that changes speed.
A thread pool sized at two hundred is a statement that at most two hundred requests may be in flight. Nobody usually derives that number; it comes from a default, a previous team’s guess, or the point at which the machine ran out of memory. The number has a meaning, though, and Little’s Law gives it one.
For any stable system, the average number of items in the system equals the arrival rate multiplied by the average time each item spends in it: L = λW. The relation requires only that the system is in steady state and that the averages are taken over the same interval. It assumes nothing about the distribution of arrivals or service times, which is why it applies to systems whose queueing behaviour is nothing like an M/M/1.
The limit is a product, not a guess
Rearranged for a service, the law says the concurrency needed to sustain a throughput λ at a latency W is λ × W. A service handling 500 requests per second with a 40-millisecond latency needs 20 requests in flight on average. That is the whole calculation, and it produces a number far below the typical default pool size.
The useful reading is the inverse. Fixing concurrency at L and observing latency W implies the throughput the service can sustain, λ = L / W. If the service is not achieving that throughput, requests are waiting somewhere that W does not include, which is the measurement problem described in Queue Time, Service Time, and What a Timeout Measures.
Concurrency is a lever on throughput only while the system is below its capacity. Once the bottleneck resource is saturated, adding in-flight requests does not raise λ; it raises W proportionally, and L = λW stays satisfied with a larger W and the same λ. The extra concurrency has bought nothing except latency, and it has consumed memory and scheduling capacity to hold the extra requests.
That is the argument for a limit rather than a pool sized for the peak: beyond the knee of the throughput curve, every additional concurrent request makes the system worse for all of the others.
Finding the knee without measuring it directly
The knee is where throughput stops rising with concurrency. Measuring it requires a load test, and a load test measures the system as configured at that moment, with that dependency latency, that data distribution, and that hardware. The number expires.
An adaptive limit avoids the measurement by treating latency as the signal. The reasoning: when the system is below its knee, latency is roughly flat as concurrency rises, because requests are served without waiting. When concurrency passes the knee, latency rises because requests are queuing. The rise in latency is the observable that marks the boundary.
The mechanism used by the Netflix concurrency-limits approach is a gradient estimate. Over a measurement window, the limit is adjusted in proportion to the ratio of a short-term latency sample to a long-term one. If the short-term latency is lower than the long-term latency, the system is speeding up and the limit can grow; if it is higher, the system is slowing and the limit should shrink.
// one window of the gradient update; the limit moves multiplicatively
double gradient = longTermRtt / shortTermRtt;
int newLimit = (int) Math.max(minLimit, limit * gradient + queueSize);
// clamp growth so a noisy window cannot double the limit
newLimit = Math.min(newLimit, limit + maxIncrement);
limit = newLimit;
The queueSize term is a fixed allowance for requests queued in the network rather than in the server, which keeps the limit from collapsing to the minimum on a single noisy window. The clamp on growth is the part that makes the scheme stable: without it, a window in which latency briefly halves doubles the limit, the doubled limit saturates the system, latency rises, and the next window halves it again. The oscillation is the classic control-loop failure, and bounding the step size is the standard fix.
Why latency and not queue length
A queue-length signal is more direct, and it is unavailable in most deployments. The client cannot see the server’s queue, and the server’s queue length depends on a concurrency model that may have no queue at all. Latency is visible at the client, includes the queue whether or not the client knows it exists, and is the quantity the client actually cares about. It is a noisy proxy for the thing being controlled, and it is the only proxy that is universally observable.
The noise has structure. A single slow request raises the window’s mean, and with a small window a single outlier can drive the limit down sharply. Percentile-based windows are less sensitive to a single outlier than means are, and respond more slowly. The choice between them is a choice about how fast the limit should react to a change in dependency latency versus how much it should ignore individual outliers.
Limits per dependency, not per service
A service calls three dependencies, and the right limit for each is different. A limit on the total in-flight count of the service says nothing about how the concurrency is distributed, so a dependency that slows down can absorb the entire allowance while the other two dependencies sit idle. The requests that need the idle dependencies are blocked behind requests that need the slow one.
A per-dependency limit allocates concurrency where it is spent. The total in-flight count is then the sum of the per-dependency limits, which is not a number anyone chose, and that is acceptable because the resource being protected is the dependency’s capacity rather than the caller’s threads.
The composition rule is that a per-dependency limit is a limit on calls, not on requests. A request that makes four calls to the same dependency consumes four units of that dependency’s limit, which means the effective request concurrency is the call concurrency divided by the fan-out. Ignoring the fan-out produces a limit that is met while the dependency receives several times the intended load.
Interaction with queues and breakers
An admission limit and a bounded queue are the same decision expressed in different units. A queue of depth D with L workers holds L + D requests; the limit is L, the queue is the buffer, and the shed threshold is L + D. Setting a concurrency limit without a queue is a pure shed-on-full policy, and setting one with a large queue reintroduces the latency growth the limit was meant to prevent. Bounded Queues and Load Shedding at the Admission Boundary works through the depth arithmetic.
A circuit breaker and a concurrency limit catch different failures. The breaker catches a dependency that returns errors; the limit catches a dependency that returns slowly. A dependency whose latency doubles without producing errors will never trip an error-rate threshold, and its effect is entirely on the caller’s concurrency budget. A latency-aware limit shrinks in that case, which is the correct response, and it does so without any state machine. Circuit Breaker State Machines and Half-Open Probing covers the error-driven case.
The two mechanisms can also conflict. A breaker that opens removes calls from the in-flight count, so the limit sees a drop in latency and grows, and when the breaker closes the grown limit admits a burst. Coordinating the two requires that the limit’s measurement window not treat a breaker-open period as evidence of health. The simplest correct rule is to freeze the limit while any breaker to the same dependency is open.
The steady-state assumption
Little’s Law holds in steady state. Every practical use of it happens in systems that are not in steady state, and the deviations matter.
During a traffic ramp, the in-flight count lags the arrival rate by roughly one latency, so a limit computed from the current rate and the current latency underestimates what the new rate requires for a brief interval. The lag is small relative to the ramp in most cases and large when the ramp is faster than the latency, which happens at the start of a load spike.
During a dependency slowdown, latency rises before the limit reacts, so the product λ × W temporarily exceeds the limit and requests queue at the admission point. That is the intended behaviour: the queue is the buffer that absorbs the mismatch, and its depth should be sized for the reaction time of the limit rather than for the traffic.
After a partial failure, the surviving capacity is smaller and the limit is still at its pre-failure value. The limit has to shrink, and it shrinks only if latency rises, which it does if the remaining capacity is saturated. If the failure removes capacity without raising latency on the survivors, the limit stays too high and the survivors are oversubscribed. Metastable Failure and the Load That Keeps a System Down treats that state, where the system’s capacity has fallen and its own retry behaviour holds it there.
The practical residue is that a concurrency limit is a control loop with a setpoint that is not a fixed number. It needs a floor so that it cannot collapse to zero under a burst of latency noise, a ceiling so that it cannot grow without bound when the dependency is idle, and an operator-visible value so that a limit pinned at its floor is diagnosable as a dependency problem rather than as a traffic problem.
References
- A Proof for the Queuing Formula: L = λW — J. D. C. Little, Operations Research, 1961
- Performance Modeling and Design of Computer Systems: Queueing Theory in Action — Mor Harchol-Balter, Cambridge University Press, 2013
- Performance Under Load — Netflix Technology Blog