Queueing: Why Systems Get Slow Before They Get Broken
Load rises 20% and latency rises 400%. Nothing errored, no code changed, no dependency degraded. A queue formed — and queues turn a linear increase in arrivals into a non-linear increase in waiting.
Frame the diagnosis
Performance work starts from a symptom and a signal — never from a resource dashboard.
The queues nobody declared
A queue exists wherever work can arrive faster than it can be served, which in a real system is nearly everywhere. Most of them are not called queues and are never instrumented: the kernel's accept backlog holding connections before your process calls accept, the runnable queue of threads waiting for a core, the pool waiter list, the disk request queue, the lock wait list, the socket send buffer. The application-level job queue that everyone thinks of is usually the *only* one with a dashboard.
This is why "the system is slow but nothing is at 100%" is such a common and confusing report. The waiting is real, but it is happening in a queue nobody exposed, so every instrumented number looks acceptable. The first move is therefore not to look for a busy resource but to look for a growing wait — depth, waiter count, or the gap between when work arrived and when it started.
Each of these queues has depth elsewhere in Engineer Atlas: the run queue and context switching in The Scheduling Problem, socket buffers in The Buffer Chain, lock waits in Locks and Deadlocks, the pool waiter list in Connection Pool Saturation: Waiting in Front of an Idle Database. What this lesson adds is the behaviour they all share once utilization gets high.
The knee: why 90% utilization is not 90% of the way to a problem
The defining property of a queue is that waiting grows non-linearly with utilization. In the simplest model — one server, random arrivals, exponentially distributed service times — the average time in system is W = S / (1 − ρ), where S is service time and ρ is utilization. That denominator is the whole story: at 50% utilization a request takes twice its service time; at 90% it takes ten times; at 99% it takes a hundred times.
The practical reading is that the last 10% of capacity costs more than the first 90%. Going from 50% to 60% utilization adds half a service time of waiting. Going from 90% to 95% doubles the total. This is why a system can absorb months of gradual traffic growth with no visible change and then degrade dramatically over a single week — the traffic did not change character, it crossed the knee.
The model's assumptions are wrong for most real systems: services have multiple workers, arrivals are bursty rather than Poisson, service times are not exponential, and queues are bounded. Multiple servers soften the curve; bursty arrivals and high service-time variance sharpen it. So treat the table below as the *shape* — non-linear, knee somewhere in the 70–90% region, catastrophic above it — rather than a lookup table for your service. The number that matters is where *your* curve bends, and only measurement gives you that (Load Testing: What Question Is This Test Answering?).
| Utilization ρ | Time in system ÷ service time | Avg items queued | What it feels like |
|---|---|---|---|
| 50% | 2× | 1 | Comfortable; spikes absorbed without notice |
| 70% | 3.3× | 2.3 | Normal-looking; the curve has started to bend |
| 80% | 5× | 4 | Latency visibly worse; still "not maxed out" on a dashboard |
| 90% | 10× | 9 | The week everything got slow with no deploy to blame |
| 95% | 20× | 19 | Timeouts begin; retries start adding load (Retry Storms: The Load You Generated Yourself) |
| 99% | 100× | 99 | Effectively an outage while every resource chart reads "99%, not 100%" |
The feedback loop that turns slow into down
Queueing degradation is self-reinforcing, and the mechanism is worth knowing precisely because each step looks locally reasonable. Latency rises past a client timeout. The client retries — correct behaviour for a transient failure. The retry is a *new arrival*, so the arrival rate increases while the service rate has not changed. Utilization rises, the queue grows, latency rises further, more requests cross the timeout, more retries arrive. The system converges on a state where most of the work being served is work whose requester has already given up.
Two things make this worse. Requests that time out still consumed capacity — the work was done, the answer was discarded — so effective throughput falls exactly when demand is highest (Throughput: The Number That Means Nothing Without a Latency Bound). And synchronised retries arrive in waves, so the queue receives bursts rather than a smooth increase.
The interventions are all about breaking the loop rather than serving it faster: bound the queue so excess work is rejected quickly instead of accepted and delayed, add jitter so retries do not synchronise, cap retry attempts, and open a circuit when a dependency is clearly failing (Circuit Breaker, Backpressure). Shedding load feels wrong during an incident and is almost always the correct move: a fast rejection preserves capacity for the requests that can still be served in time.
Key points
- Queues exist at every hop — accept backlog, run queue, pool waiters, disk queue, lock waits — and most are never instrumented.
- Waiting grows non-linearly with utilization: the last 10% of capacity costs more latency than the first 90% combined.
- A system can absorb gradual growth invisibly and then degrade sharply in one week because traffic crossed the knee.
- Timeouts plus retries close a feedback loop: latency causes retries, retries cause load, load causes latency.
- The fix for a saturated queue is usually to admit less work, not to serve it faster.
Progressive depth
Overview
When work arrives faster than it can be served, it lines up and waits. The waiting — not the work — is what makes a busy system feel slow, and every hop in a request path has a line of its own.
Practical
Measure wait time separately from service time, watch queue depth as a trend rather than a level, and plot latency against arrival rate to find where your curve bends. Operate below that point deliberately, and bound queues so excess load is rejected quickly rather than accepted and delayed (Concurrency Limits: An Unbounded Server Is a Slower Server, Headroom: The Capacity You Deliberately Do Not Use).
Advanced
Utilization drives waiting non-linearly, so capacity planning is about distance from the knee rather than about average headroom. Retries and timeouts create a positive feedback loop that turns degradation into collapse, and bursty arrivals mean the effective utilization during a burst is far above the five-minute average you are charting (Retry Storms: The Load You Generated Yourself, Coordinated Omission: When the Load Generator Lies).
Internals
The queues are physical structures with their own limits and drop behaviour: the kernel accept backlog bounded by the listen backlog, socket buffers subject to flow control (Flow Control: The Receive Window, The Buffer Chain), the scheduler run queue with its own policy and preemption cost (The Scheduling Problem, Context Switching), the storage request queue reordering and merging I/O (Disk and Storage: Latency, Throughput, IOPS and the fsync Tax), and lock wait lists inside the database (The Lock Manager). Each has different overflow semantics — block, drop, or reject — and those semantics decide whether overload degrades or collapses.
The Queueing Curve
Change an input and watch which number moves — and which one does not.
At ρ = 0.80 the queue is real but modest. This is the last comfortable zone — note how little headroom is left before the curve turns upward.
Follow the diagnosis
The causal chain, hop by hop — and the readings that invite the wrong conclusion.
- 1Traffic → service: arrivals grow from 4,000 to 5,000 rps, pushing the bottleneck resource from ~72% to ~90% utilization.
- 2Utilization → queue: at 90% the wait multiplier is roughly ten service times rather than three — the same work now waits far longer.
- 3Queue → latency: p99 rises from 180 ms to 2.4 s with no change in the code path or the service time itself.
- 4Latency → clients: requests cross the 2 s client timeout; clients retry, adding arrivals on top of the original load.
- 5Retries → utilization: effective arrival rate rises above 5,000 rps, pushing utilization higher and closing the loop.
- • "Nothing is at 100%, so nothing is saturated" — the knee arrives well before 100%, and the resource that matters may not be the one being charted.
- • "Latency quadrupled, so something must have broken" — non-linear degradation from a linear traffic increase is the normal behaviour of a queue, not evidence of a fault.
- • "Add retries to improve reliability" — retries against a queueing system add arrivals to the thing that is already over capacity (Retry Storms: The Load You Generated Yourself).
- • "The service got slower, so profile the code" — service time did not change. A profiler will faithfully show the same hot path it always showed.
- • "Increase the queue size so we stop dropping work" — a longer queue converts rejections into longer waits, which usually means the same work is discarded later, after paying for it.
Measure, fix, validate
An optimization is not finished until the metric that motivated it has moved.
- • Instrument wait time separately from service time on every bounded resource: pool acquisition time, time from enqueue to start, accept-to-handler delay.
- • Plot p99 latency against arrival rate; the point where the curve bends upward is your knee, and it is service-specific.
- • Watch queue depth and waiter counts as gauges — a depth that trends upward over minutes means arrivals exceed service, not that the queue is busy.
- • Track retry rate and the ratio of retried to original requests; a rising ratio is the amplification loop starting.
- • Compare utilization against saturation for each resource — the pair, not either alone, locates the constraint ([[use-method]]).
- • Bound the queue and shed excess load quickly, so capacity goes to requests that can still be served within their deadline ([[concurrency-limits]]).
- • Add capacity or reduce service time at the constrained resource specifically — moving utilization from 90% to 70% cuts the wait multiplier by roughly two thirds.
- • Cap retries, add exponential backoff with jitter, and open a circuit on sustained failure so the feedback loop cannot close ([[circuit-breaker]]).
- • Set client timeouts and server-side deadlines consistently so work whose requester has abandoned it is not served ([[timeouts-and-latency]]).
- • Operate with deliberate headroom below the knee rather than as close to full utilization as the dashboard tolerates ([[headroom]]).
- • Re-plot latency against arrival rate and confirm the knee moved right — the same traffic should now sit further down the flat region.
- • Confirm wait time specifically fell, not just total latency; if service time changed too, you have two variables and no conclusion.
- • Check that the retry ratio returned to baseline, which is the evidence that the amplification loop is actually broken.
- • Verify effective throughput at peak improved, since queueing collapse shows up as completed work falling below offered load.
- • Operating below the knee means paying for capacity that is idle most of the time — the insurance premium for predictable latency.
- • Load shedding means some users receive a fast failure instead of a slow success; that is a product decision, not purely a technical one.
- • Bounded queues make failures visible and abrupt rather than gradual, which is better operationally but worse for anyone who preferred not to be told.
- • Alert on wait time or queue depth trending upward, which leads the latency alert by minutes and is far more actionable (Alerts Worth Waking Someone For).
- • Track utilization of the known bottleneck against the measured knee and alert on approach, not on 100%.
- • Include a stepped load test in the release process that asserts the knee has not moved left (Load Test Shapes: The Shape Is the Hypothesis).
Accuracy
Performance numbers are conditional. These are the conditions.
- ESTIMATEDThe utilization table is computed from
1/(1−ρ), the mean time in system for an M/M/1 queue: a single server, Poisson arrivals, exponentially distributed service times, unbounded queue. Real systems violate all four assumptions. Multiple servers flatten the curve; bursty arrivals and variable service times steepen it. The shape transfers; the constants do not. - WORKLOAD-SPECIFICWhere the knee sits for a given service depends on service-time variance, concurrency, and how bursty arrivals are. Measure your own curve rather than assuming 80% is safe.
Misconceptions
1/(1−ρ), so latency degrades severely well before saturation. At 90% utilization a request already waits roughly ten times its service time. Utilization is a poor health signal precisely in the region where it matters most.Apply it
Where the depth lives
The 1/(1−ρ) relationship is the smallest model that reproduces the behaviour engineers actually observe. Its assumptions are wrong for real systems, but no simpler model explains why a 20% traffic increase can quadruple latency, which is why it remains the right mental picture.