Latencyqueueingutilizationkneesaturationbacklogretries

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.

▶ Run the labFollow the diagnosis

Frame the diagnosis

Performance work starts from a symptom and a signal — never from a resource dashboard.

Diagnostic question
Why does latency explode from a modest increase in traffic, and which queue in the system is actually doing the waiting?
Symptom
Traffic grew from 4,000 to 5,000 requests per second over a week. p99 went from 180 ms to 2.4 seconds. Error rate is still near zero and every dashboard shows resources "not fully utilized".
Signal
The **queue depth or wait-time gauge** on the constrained resource confirms it, together with **latency plotted against arrival rate**. Utilization is the signal that misleads: at the point where waiting becomes intolerable, utilization often reads a comfortable-looking 80–90%.
SymptomSignalMeasurementHypothesisEvidenceRoot CauseChangeValidationRegression Check

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.

Every hop is a queue, and only one of them usually has a dashboard
arrivalsaccept()needs a coreneeds a connectionacquirepage readClientsAccept backlogWorker poolCPU run queueDB pool waitersDatabaseDisk queue
UserLLMAgentToolDataDecisionHumanGuardrail

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?).

Wait multiplier against utilization. ESTIMATED from `1/(1−ρ)` for a single-server queue with random arrivals; real curves differ in constants but not in shape.
Utilization ρTime in system ÷ service timeAvg items queuedWhat it feels like
50%1Comfortable; spikes absorbed without notice
70%3.3×2.3Normal-looking; the curve has started to bend
80%4Latency visibly worse; still "not maxed out" on a dashboard
90%10×9The week everything got slow with no deploy to blame
95%20×19Timeouts begin; retries start adding load (Retry Storms: The Load You Generated Yourself)
99%100×99Effectively 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.

The retry amplification loop
new arrivals — the loop closeswork completed, answer discardedArrival rate risesQueue growsWait time risesRequests cross client timeoutClients retryCapacity spent on abandoned work
UserLLMAgentToolDataDecisionHumanGuardrail

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.

One server: arrivals against capacity
ESTIMATED
utilisation ρ
0.80
mean wait
40 ms
ρ = 0ρ → 1wait time

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.

  1. 1
    Traffic → service: arrivals grow from 4,000 to 5,000 rps, pushing the bottleneck resource from ~72% to ~90% utilization.
  2. 2
    Utilization → queue: at 90% the wait multiplier is roughly ten service times rather than three — the same work now waits far longer.
  3. 3
    Queue → latency: p99 rises from 180 ms to 2.4 s with no change in the code path or the service time itself.
  4. 4
    Latency → clients: requests cross the 2 s client timeout; clients retry, adding arrivals on top of the original load.
  5. 5
    Retries → utilization: effective arrival rate rises above 5,000 rps, pushing utilization higher and closing the loop.
What this evidence makes people conclude — wrongly
  • "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.

How to measure it
  • • 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]]).
What actually fixes it
  • • 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]]).
How you know it worked
  • • 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.
What it costs
  • • 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.
Stop it coming back
  • 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.

What these numbers depend on
  • 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

Claim
“A resource is fine until it reaches 100% utilization.”
Reality
Waiting scales with 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.
Claim
“If latency jumped, something must have broken.”
Reality
A queue converts a linear traffic increase into a non-linear latency increase. Nothing has to break for latency to quadruple — crossing the knee is sufficient, and it looks identical to a fault on most dashboards.
Claim
“Bigger queues protect the system from bursts.”
Reality
A larger queue absorbs a burst, which is useful, but under sustained overload it only lets work wait longer before being abandoned. Beyond the point where wait exceeds the client timeout, extra queue capacity is capacity spent on work nobody will use.

Apply it

Where the depth lives

Queueing theory
M/M/1 and the utilization law

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.