Deep Engineering
Intermediate·Published·30 MIN

Queues and tails: why 19 % more load makes latency four times worse

Utilisation went from 0.80 to 0.95 — a rise of nineteen percent. Time in the system rose fourfold. The lesson takes that disproportion apart and, along the way, refutes the received wisdom that "the tail grows faster than the mean": in a pure queue it does not, and what pulls the tail away from the middle is something else.

Full technical treatment

TL;DR

The latency of a request is service time plus waiting time, and what grows with load is the waiting. While the server keeps up, gaps appear between requests and the queue drains in them. The closer the arrival rate gets to what the server can handle, the rarer those gaps become — and the longer every random burst of requests stands around. The work itself does not get a millisecond more expensive.

Hence the main consequence: latency grows out of proportion to load. In the model, utilisation rose 1.19x (from 0.80 to 0.95) while the mean time in the system rose 4.1x, from 51.3 ms to 212.9, with service unchanged at 10 ms. So "we still have fifteen percent of headroom" is not fifteen percent of latency: running at 0.95 costs four times the latency of 0.80 on the same hardware with the same code. The same mechanism works in reverse: at an unchanged arrival rate, speeding up the bottleneck lowers not only service time but utilisation too, so near the limit even 20 % buys a multiple — in the model the mean fell from 212.9 ms to 34.1. The same goes for charts: the mean and the median move together with the tail, so "average latency is fine" does not yet prove that p99 is.

Beyond that are the numbers, the boundaries and one refutation. There is one model here and it is named outright: M/M/1 — a single server, an unbounded queue, exponential inter-arrival times and exponential service with a 10 ms mean. In that model, raising utilisation from 0.80 to 0.95 grew p99 by 4.1x — exactly as the mean did — and the p99-to-mean ratio stayed put: 4.43 against 4.38. What follows is not the opposite law but the absence of one: "the tail always grows faster than the mean simply because of the queue" is a bad generalisation, because in M/M/1 it grows at the same rate. With a heavy-tailed service time, several servers, batching or priorities the picture differs — and this lesson measures one such case: at the same utilisation of 0.80 and the same 10 ms mean service time, replacing even service with "5 ms in 95 % of cases and 105 ms in 5 %" raised p99 3.1x and the median 2.6x. Which is also why the reverse picture on a chart — "the tail jumped and the mean barely moved" — points at spread rather than at load. And one more boundary: the ρ of the model is not the CPU utilisation on your dashboard.

Where to start
Before this lesson it is enough to understand
  • a server does not handle requests instantly: each one takes some amount of time;
  • requests arrive whenever they arrive rather than on a schedule, so sometimes they arrive in bursts;
  • a request that reaches a busy server waits its turn.
You do not need to know in advance
  • percentiles, and how p99 differs from the mean and the median;
  • utilisation ρ, the M/M/1 model, the exponential distribution, "the spread of service times".

What is actually being asked

The ladder usually runs like this:

  1. "What is p99?" — the warm-up.
  2. "Why can you not look at the mean?" — where the substance starts.
  3. "Load grew by a fifth and latency went up fourfold. Why?" — the central question.
  4. "How highly can you load a service?" — about headroom.
  5. "Why is the tail longer than the middle?" — usually answered with "because of load", which is wrong.
  6. "What do you do about the tail?" — about what can actually be changed.
model with assumptionsThe numbers in this lesson come from the simulation in bench/queueing/mm1.py rather than from a live system. The assumptions are in the docstring: one server, an unbounded queue, exponential inter-arrival times and service with a 10 ms mean, a hundred thousand requests per point, a fixed seed.

The numbers in this lesson come from a model rather than from a run on a live service. The reason is the same as in the lesson on retries: observing how latency depends on utilisation means changing the load on a working service. The model gives the same dependency with named assumptions; in exchange the reader must remember that a real service has several workers, a bounded queue and timeouts.

Base: a queue appears when requests arrive faster than they are served

Before talking about utilisation, percentiles and models it is worth naming, in ordinary words, where a queue comes from at all.

Suppose a server can handle ten requests per second. That is its limit — it cannot do more.

  • Eight arrive per second. The server keeps up. Sometimes requests arrive in a burst and somebody has to wait, but a gap follows the burst and the queue drains inside it. There is headroom, and the headroom is those gaps.
  • Ten arrive per second. The server is busy the whole time. There are no gaps any more — and with no gaps there is nothing for the queue to drain into: whatever piled up during a burst stays there and keeps growing.

That is the whole reason a queue behaves so strangely near the limit: what disappears is not capacity but the gaps.

The second thing to name before anything else is what the latency a user sees is made of:

service time + waiting time = latency

Service time is how long the server spends on the request itself. Waiting time is how long the request stood in the queue while the server was busy with others. The user sees only the sum, but the two terms change differently — and that is what this lesson is about.

And here is the central question: which of the two terms grows when things "got slower"? The work has not changed: the same code, the same hardware, the same request.

The answer: the waiting grows. Service stays exactly what it was, while time in the queue keeps increasing — because the gaps that drain it keep getting rarer.

That is already enough to answer the basic interview question. Everything below is about how disproportionately the waiting grows, where the gap between the middle and the tail comes from, and why what moves that gap is not what people usually assume.

Mechanism 1: a queue gets expensive faster than load grows

TIME IN THE SYSTEM AGAINST UTILISATION
--------------------------------------
    load       mean        p50        p95        p99
    0.50      20.3       14.0       61.2       94.0 
    0.70      34.1       23.6      103.2      158.1 
    0.80      51.3       35.8      155.7      227.2 
    0.90     109.2       74.1      331.9      503.2 
    0.95     212.9      152.9      634.0      932.5 
model with assumptionsbench/queueing/mm1.py, a model. The absolute milliseconds follow from the chosen 10 ms mean service time; what carries meaning is the ratios between rows.

The "load" column is the fraction from the Base: eight requests out of ten the server can take is utilisation 0.8, nine and a half is 0.95. Formally, the utilisation ρ of the model is the share of time the single server is busy.

And straight away, the boundary without which that number misleads: the ρ of the model is not the CPU utilisation on your dashboard. CPU says how hard the hardware is working; ρ says how densely occupied the one place where the queue forms is. They need not agree: the bottleneck may be a disk, a database connection, a worker pool or a lock — and then the queue grows by every rule in this table while the CPU chart stays calm. Read the table with the ρ of the resource that actually constrains you, not of the most visible one.

Read the "mean" column: 20 → 34 → 51 → 109 → 213. Load grows in ever smaller steps; time accelerates.

The reason is not that the service got tired. Service takes the same 10 ms throughout; only waiting grows. The mechanism: while the worker is busy, new requests pile up, and the higher the utilisation the rarer are the gaps in which a queue drains. At 0.5 there are many gaps, at 0.95 almost none, and each random burst takes longer and longer to clear.

Hence the rule worth answering with: the last percentage points of utilisation are the most expensive. In the "mean" column the step from 0.5 to 0.8 is 20.3 → 51.3, and the next step, from 0.8 to 0.95, is 51.3 → 212.9 — fourfold.

Mechanism 2: putting a number on the disproportion

WHAT GROWS FASTER THAN WHAT
---------------------------
  load 0.80 -> 0.95 is a rise of              1.19x
  mean time rises by                          4.15x
  p99 rises by                                4.10x
  p99 over mean at load 0.80                  4.43x
  p99 over mean at load 0.95                  4.38x
model with assumptionsbench/queueing/mm1.py, a model. Lines 4 and 5 are where the model refutes the received formulation rather than confirming it.

The first two lines answer the central question: load rose by 19 %, latency fourfold. That is the disproportion that makes plans of the form "we still have fifteen percent of headroom" wrong: fifteen percent of utilisation is not fifteen percent of latency.

The fourth and fifth lines are why the model is worth reading closely. The ratio of p99 to the mean did not change: 4.43 against 4.38. In M/M/1 — the model this whole table comes from — p99 and the mean scale identically: the tail grows at exactly the rate the middle does, not faster.

Why that happens is visible in the model's own construction, and it is worth naming: time in the system here is exponentially distributed, and for an exponential distribution any quantile is the mean times a constant factor. That is why the ratio of p99 to the mean cannot change — what grows is the factor in front of it, not the ratio.

Now, carefully, about what follows from that. It does not follow that the tail never grows faster than the mean. Exactly one thing follows: "the tail always grows faster than the mean simply because of the queue" is not a general law — there exists a queue where it is untrue, and it happens to be the most thoroughly studied queue in existence. The rule is written from the boundary: in M/M/1 the tail and the mean scale together; outside it, that is an open question.

And a real system leaves that boundary in many ways. A heavy-tailed service time (one class of requests that takes an order of magnitude longer than the rest), several servers instead of one, batching (requests processed in groups), priorities (some requests let through first) — in each of those the picture differs, and the tail may well part company with the middle. The first of them is measured in this lesson, and that is where we go next.

Mechanism 3: the tail is made by spread, not by load

The hypothesis: what pulls the tail away from the middle is the spread of service times, not the load. The test — the same load, the same average service time, only the spread changes: instead of "10 ms on average", "95 % of requests take 5 ms and 5 % take 105 ms". The mean of that is exactly the same: 0.95 × 5 + 0.05 × 105 = 10.

SAME MEAN SERVICE TIME, DIFFERENT SPREAD
----------------------------------------
  both runs: load 0.80, average service time 10 ms
  A: service time exponential, average 10 ms
  B: service time is 5 ms in 95% of cases and 105 ms in 5%

                           mean        p50        p99
      A, exponential      51.3       35.8      227.2
            B, mixed     139.1       92.2      713.8
  p99 of B over p99 of A                      3.14x
  p50 of B over p50 of A                      2.58x
model with assumptionsbench/queueing/mm1.py, a model. Both rows were taken at utilisation 0.80 with the same average service time; only the spread differs.

Everything grew — the median and the tail alike — but the tail grew further: 3.1x against 2.6x. And utilisation did not move by a single percent, nor did the average work per request.

This is exactly the step outside M/M/1 mentioned above: the moment service time acquired a heavy tail, the tail of latency moved faster than the middle. Note the order of the claims — not "the tail grows faster than the mean because queues", but "the tail grows faster than the mean when the spread changes; load alone is not enough to do it".

Hence the phrasing to answer with: the tail is made by spread, not by load. Five percent of slow requests is not "five percent of users will wait"; it is a queue forming behind them, and everybody else joins it.

And the practical consequence: if the tail has parted company with the middle, look for the slow class of requests. Adding capacity at the bottleneck lowers utilisation and compresses the whole distribution — the tail together with the middle; removing spread moves the tail specifically. Which lever is stronger depends on how close the bottleneck is to saturation — that is the next section.

Mechanism 4: speeding up the bottleneck at the same arrival rate

The first table invites a wrong conclusion. At utilisation 0.95 a request spends 212.9 ms in the system on average, of which service is 10 ms — about five percent; so speeding up service is pointless? No: that reasoning holds waiting constant, and waiting itself depends on how fast service is. With the same arrival rate, a faster server is busy less often — utilisation falls together with service time, and the queue shrinks.

The check uses the same model and the same seed: the arrival rate is held at 95 requests per second, and only the server changes.

SAME ARRIVALS, MORE CAPACITY: 95 REQUESTS PER SECOND IN EVERY ROW
-----------------------------------------------------------------
  arrivals: 95 requests per second in every row, seed 20260905

                                       util     mean      p50      p99   theory
           baseline: 1 server, 10 ms  0.950    212.9    152.9    932.5    200.0
        service -20%: 1 server, 8 ms  0.760     34.1     23.8    153.1     33.3
    capacity x1.2: 1 server, 8.33 ms  0.792     41.0     28.6    181.6     40.0
            second server: 2 x 10 ms  0.475     13.0      9.5     55.4     12.9
      one server twice as fast: 5 ms  0.475      9.6      6.7     44.7      9.5

  baseline row equals block 1 at load 0.95   True

                against the baseline  mean falls  p99 falls
        service -20%: 1 server, 8 ms       6.24x      6.09x
    capacity x1.2: 1 server, 8.33 ms       5.19x      5.13x
            second server: 2 x 10 ms      16.34x     16.83x
      one server twice as fast: 5 ms      22.08x     20.85x
model with assumptionsbench/queueing/mm1.py, the fourth block, a model. Every row has the same arrival stream — 95 requests per second; the first row matches block 1 at utilisation 0.95. The theory column is the M/M/c mean for the same parameters. The two-server row breaks the 'single server' assumption: the two share one queue.

Service 20 % shorter (10 → 8 ms) lowers utilisation from 0.95 to 0.76, and the mean falls from 212.9 to 34.1 ms — 6.2x, p99 6.1x. If "20 % faster" means 1.2x the capacity (8.33 ms), the gain is 5.2x. The M/M/1 formula for the mean, E[T] = 1/(μ − λ), gives the same direction and the same order: 200 → 33.3 ms; right at the limit a finite simulation strays from the formula by a few percent.

A second server is a capacity gain too, only a twofold one, and its gain is correspondingly larger: 16x on the mean. Comparing it with a 20 % speedup compares different gains. At an equal gain, one server twice as fast gives an even lower mean here, 9.6 ms against 13.0: with two servers every request is still served in 10 ms. And a second server helps only if the work really splits: a shared lock or a shared database leaves the bottleneck where it was.

The opposite extreme — "speeding up the code always pays best" — is wrong too. What the same speedup buys depends on how close the bottleneck is to saturation:

THE SAME 20% SHORTER SERVICE AT DIFFERENT ARRIVAL RATES
-------------------------------------------------------
  1 server; service 10 ms -> 8 ms; arrivals fixed within each row

  arrivals/s         util  mean, 10 ms  mean, 8 ms    falls
          50   0.50->0.40         20.3        13.4    1.51x
          80   0.80->0.64         51.3        22.7    2.26x
          95   0.95->0.76        212.9        34.1    6.24x
model with assumptionsbench/queueing/mm1.py, the fourth block, a model. In each row the arrival rate is the same before and after the speedup; only the mean service time changes, 10 → 8 ms.

Far from the limit (50 requests per second) the same 20 % buys one and a half times; near it, six. And it is the bottleneck that is worth speeding up: a faster step that has no queue in front of it does not lower the utilisation of the resource where the queue actually forms.

Deeper: what the model leaves out

The model behind this lesson is M/M/1, and its name is worth carrying alongside its numbers: almost every conclusion holds inside its boundaries rather than in general. Four things worth saying yourself.

There is one worker. A real service handles several requests in parallel, which changes the numbers a lot: a queue in front of several workers drains faster, and the same utilisation costs less. The direction of the dependency survives; the exact multiples do not. This is also where "p99 and the mean grow together" breaks: that equality was shown for M/M/1 and nowhere else.

The queue is unbounded. Nobody is refused and nobody leaves. A real system has an accept queue of finite length — the one described in tcp(7) and taken apart in the lesson on the accept queue — and client timeouts (the lesson on timeouts): both cut the tail off — at the price of errors instead of waiting.

There are no retries. In life there are, and they add load exactly when the system is at its limit (the lesson on retries). The real curve is steeper than the model's by that contribution.

And ρ once more. Utilisation in the model is the share of time the one place where the queue forms is busy. A dashboard usually has no such number: it has CPU utilisation, while the queue may be sitting at a connection pool, a disk or a lock. Applying this table to CPU percentages is the most common mistake made when carrying these conclusions into a live system.

How to answer in an interview

Short answer: time in the system is service plus waiting, and what grows with utilisation is the waiting, not the service. So the dependency is disproportionate: in the model, raising utilisation from 0.80 to 0.95 is plus 19 %, and time grew 4.1x with the work unchanged at 10 ms per request.

That is enough for a correct answer. What follows is what you add when the interviewer digs.

If the interviewer digs deeper

Three things separate a good answer. First, you do not pass "the tail grows faster than the mean" off as a law of queues: in the M/M/1 model the p99-to-mean ratio is constant (4.43 against 4.38), so load alone stretches the whole distribution as one. Second, you name the real cause of a long tail — the spread of service times; in the same model, at the same load and the same mean, swapping even service for "95 % at 5 ms and 5 % at 105 ms" tripled p99 and raised the median a little over twofold. Third, you draw the practical conclusion at a fixed arrival rate: if the tail has parted from the middle, you look for the slow class of requests; if the whole distribution is growing, the bottleneck needs capacity — and near the limit even a 20 % gain cuts time by a multiple (in the model at 95 requests per second, from 212.9 to 34.1 ms), while far from it the gain is much smaller.

And two things that are easy to overdo. First: "p99 and the mean grow together" is a property of M/M/1, not a law of queues. With a heavy-tailed service time, several servers, batching or priorities the tail does part company with the middle — the spread measured in this lesson is exactly such a case. Second: the ρ of the model is not the CPU utilisation on a dashboard; before applying the table to your system, work out which resource the queue is actually forming at.

Next they ask

Next they ask

How highly can a service be loaded?

Short answer

The answer follows from the table rather than from habit: in the "mean" column 0.5 → 0.8 is 20.3 → 51.3, and 0.8 → 0.95 is 51.3 → 212.9 — fourfold. So "how much" is a question about the latency you are willing to pay and about how sharp your spikes are.

Headroom exists for the spike, not for the average: a system sitting at 0.95 falls into a bottomless queue on a short peak, while one at 0.7 rides it out and returns.

Next they ask

Which is better: a second worker or making processing 20 % faster?

Short answer

Compare them at the same arrival rate, and then both do the same thing — add capacity at the bottleneck, which lowers utilisation and with it the waiting. A speedup does not only trim the "five percent" of service: with the same arrivals the server is busy less often, and the queue shrinks with it. In the model at 95 requests per second, service 10 → 8 ms cut the mean from 212.9 to 34.1 ms, and a second worker cut it to 13.0.

The second worker won because it is a twofold gain, not a quarter. At an equal gain, one server twice as fast gave 9.6 ms in the model. So the choice comes down to cost and feasibility: a second worker helps only if the work splits and does not hit a shared lock or database; a speedup helps only if it is the bottleneck that gets faster.

Next they ask

How do you shorten the tail without touching capacity?

Short answer

Reduce the spread. In the model that was the only thing that moved the tail more than the middle: the same utilisation, the same mean service time — and p99 three times higher because of five percent of slow requests.

In practice that means finding the slow class (heavy requests, a cold cache, misses that reach the database) and either speeding it up or moving it onto a separate path so that it does not delay the rest. The second is often cheaper and always more predictable.

Next they ask

Is p99 "the worst 1 % of users"?

Short answer

Not quite, and the correction matters: p99 is computed over requests, not people. If a page makes a hundred requests and each independently exceeds the p99 threshold with probability 1 %, at least one exceeds it with probability 1 − 0.99¹⁰⁰ ≈ 63 %. Independence is an assumption, not a fact: requests of one page are often slow together (one overloaded backend, one collector pause), and then the share is lower.

And "the page had a slow request" does not yet mean "the page was slow". The critical path decides that: a slow request that ran in parallel with others and finished before the longest of them did not delay the page; a chain of sequential requests adds its delays up. Hence watching not only request p99 but the share of sessions with at least one slow request, and separately the latency of the page itself.

Common misconceptions

Claim

latency grows in proportion to load

Actually

Model: a 1.19x rise in utilisation (0.80 to 0.95) produces a 4.1x rise in time in the system. Only waiting grows — service takes the same 10 ms throughout. So "we still have fifteen percent of headroom" is not fifteen percent of latency.

Claim

as load grows the tail grows faster than the mean — that is a law of queues

Actually

It is not a law. In the M/M/1 model, from 0.80 to 0.95 both the mean and p99 grew 4.1x, and the p99-to-mean ratio stayed put (4.43 against 4.38): load alone stretches the whole distribution as one. Outside that model — a heavy-tailed service time, several servers, batching, priorities — the tail really can grow faster, and this lesson measures that on spread.

Claim

a long tail is a sign of overload

Actually

Not necessarily: the tail is made by spread. In the model, at an unchanged utilisation of 0.80 and an unchanged mean service time of 10 ms, replacing even service with "95 % at 5 ms and 5 % at 105 ms" raised p99 3.1x. Utilisation did not move by a percent.

Claim

if the mean is fine, the tail is fine

Actually

In M/M/1 the mean and the tail move together, so "the mean is fine" proves nothing — it simply has not grown yet. The case of "the tail grew and the mean barely did" does occur, and it means one specific thing: the spread grew, that is, a slow class of requests appeared.

Claim

at high utilisation a request is almost always waiting, so speeding up processing is pointless

Actually

Waiting itself depends on service speed: with the same arrival rate a faster server is busy less often. Model, 95 requests per second: service 10 → 8 ms lowered utilisation from 0.95 to 0.76 and the mean time in the system from 212.9 to 34.1 ms, 6.2x; p99 fell 6.1x. There is no opposite law either: at 50 requests per second the same speedup gave one and a half times, it is the bottleneck that is worth speeding up, and if the tail has parted from the middle the lever is spread.

Claim

p99 is the worst percent of users

Actually

It is the worst percent of requests. If a page makes a hundred requests and each independently exceeds the threshold with probability 1 %, at least one exceeds it with probability 1 − 0.99¹⁰⁰ ≈ 63 % — so the share of sessions with a slow request can be dozens of times the share of slow requests. But a slow request does not by itself make the page slow: the critical path decides that. Count both requests and pages.

Claim

a single-server queue model has nothing to do with a real service

Actually

Its numbers do not: a real service has several workers, a bounded queue, timeouts and retries, and every multiple will differ. Its direction does: waiting grows faster than load, and spread pulls the tail. That is exactly why M/M/1's assumptions are named one by one and its conclusions are stated as ratios rather than milliseconds — and why its ρ is kept separate from the CPU utilisation on a dashboard.

Practice

Two exercises. Answer first, then check against the real output: in both, the correct answer comes from a run of the model rather than being written by hand.

Practice · predict the output

A single-server queue with 10 ms of service on average. Three ratios are computed: how much the mean time in the system grows when utilisation rises from 0.80 to 0.95; how much p99 grows over the same rise; and how much p99 grows at an unchanged utilisation of 0.80 when service becomes '5 ms in 95 % of cases and 105 ms in 5 %' with the same mean. What does this code print?
low = simulate(0.80)
high = simulate(0.95)
mixed = simulate(0.80, service_mixed)
print(f"{high['mean'] / low['mean']:.1f}")
print(f"{high['p99'] / low['p99']:.1f}")
print(f"{mixed['p99'] / low['p99']:.1f}")

Practice · estimate

The utilisation of a single-server queue rises from 0.80 to 0.95 — by nineteen percent. How many times does the mean time in the system grow?
times

Knowledge check

Question 1 of 6

Utilisation rises from 0.80 to 0.95. What happens to the mean time in the system?

Sources & further reading

1 SOURCE

  1. tcp(7), Linux man-pages 6.7Official documentation. The one place where this lesson leans on a document rather than a model: the queue of established connections in which a request waits its turn is the one described in listen(2)/tcp(7) and taken apart in the lesson on the accept queue. Everything else here is a simulation with stated assumptions (bench/queueing/mm1.py), not a measurement of a live system.https://man7.org/linux/man-pages/man7/tcp.7.html