A bulk transfer saturates a 12 Mbit/s link. A second, tiny flow — one packet every 150 ms, a game input or a voice frame — shares it. The only control that matters is the one nobody thinks of as a control: how much memory the router in front of the bottleneck is allowed to hold.
Drag it. The teal line does not move. The amber one goes up by a factor of twelve.
That is bufferbloat, and the reason it survived for a decade in shipping hardware is drawn here directly: every meter a vendor would have looked at says the big buffer is better. The link is 99.3% utilised either way. The drop rate falls as the buffer grows — from 1.3 discards a second down to zero. Throughput, loss, utilisation: three green numbers, and a link that has become unusable for anything interactive.
What the bottom panel is
Seven runs of the same configuration, one per buffer size, each averaged over a whole sawtooth. It is the argument compressed into fourteen numbers:
| buffer | carried | mean RTT | p95 |
|---|---|---|---|
| 0.25 × BDP | 89% | 43 ms | 49 ms |
| 0.5 × | 96% | 49 ms | 59 ms |
| 1 × | 99.3% | 63 ms | 78 ms |
| 2 × | 99.4% | 95 ms | 118 ms |
| 4 × | 99.3% | 158 ms | 195 ms |
| 8 × | 99.3% | 278 ms | 353 ms |
| 16 × | 99.3% | 525 ms | 667 ms |
The teal line is flat from one bandwidth-delay product onward because it is
pinned to the ceiling — the link cannot carry more than the link. Everything
spent on memory past that point buys delay at full price and throughput at
nothing. And the left end of the same line is why the buffer exists at all: at a
quarter of a BDP the flow’s own sawtooth leaves the link idle 11% of the time.
There is a correct answer in the middle, it is about one BDP, and it is the
classic buffer-sizing rule (B = C × RTT) drawn as the knee of a curve rather
than asserted.
The thing to actually watch
Not the numbers — the queue, in the top panel, drawn at its real capacity with the packets that are actually in it. Under drop-tail with a big buffer it never empties. It fills to the brim, a drop finally tells the sender something, the window halves, and the queue drains to half full before climbing again. The link’s memory is doing nothing but adding a constant to every packet’s journey, which is why the phenomenon’s name is a standing queue.
Every packet is one millisecond here (the link runs at exactly one packet per millisecond, 1500 bytes at 12 Mbit/s), so the buffer’s width in packets and its cost in milliseconds are literally the same number. 640 packets of memory is 640 ms of latency, written on the box.
The three fixes, and what each one actually fixes
A delay-based sender (Vegas). Reno’s control signal is loss, and loss is a thing that only happens once the buffer is full — so Reno cannot find the right window without first overrunning whatever memory you gave it. Vegas watches the RTT instead, estimates how many of its own packets are sitting in a queue, and holds that number between 2 and 4. Switch to it and the 640-packet buffer stops mattering: 3 packets queued, 43 ms, 99.3% of the link. The buffer is still there. The sender simply never fills it, which is the whole idea behind every delay- or rate-based controller since, BBR included.
CoDel on the queue. Nichols and Jacobson’s insight was that queue length is the wrong thing to measure — a burst that drains is fine, and a short queue that never drains is not — so CoDel measures how long packets sit and starts dropping once the minimum sojourn stays above 5 ms for a full 100 ms interval. It pins the round trip at 42 ms at every buffer size in this demo. It also costs 13 points of throughput here, and that number is real rather than a bug: a single Reno flow needs about a BDP of slack to cover its own sawtooth, and CoDel by design will not give it one. With dozens of flows whose sawtooths are out of phase, the aggregate keeps the link full and the cost largely vanishes — but with one flow, on one link, you can see exactly what you are trading.
Fair queueing. The one worth sitting with. Give each flow its own lane and round-robin between them, and nothing about the standing queue changes — the bulk flow still rides up to 360 ms, the buffer still fills, the sawtooth is identical. The small flow goes to 40 ms flat, at every buffer size, because its lane was empty and it never had to stand behind anyone. Delay was never a property of the link. It was a property of the queue you were made to share.
FQ-CoDel, which is what actually ships in Linux and in every decent home router now, is simply both switches on at once.
Reuse
src/standing.js is a framework-free ES module with no rendering and no timers:
newSim(cfg)/step(sim)/run(sim, ms)— a deterministic 1 ms time-stepped simulation of one bottleneck.cfgis{ bufferPkts, sender, policy }.SENDERS(reno,vegas),POLICIES(droptail,codel,fqonly,fq),BUFFERS— the axes, withaqmandperFlowas separate flags rather than one welded FQ-CoDel.sweepPoint(i, cfg)/sweep(cfg)— the same configuration at every buffer size, run to steady state, returning utilisation, mean and p95 RTT, the small flow’s RTT, and the drop rate.equilibrium(cfg),sawtoothPeriod(cfg),prime(sim)— the analytic companions: where the window settles, how long one tooth takes, and a way to start a run there instead of waiting.goodput,mbps,rttStats,quantile,thinPos.
Gotchas
- There is no RNG anywhere. Same configuration, same picture, every time, which is what lets the sweep be recomputed on a dropdown change instead of baked into a file. It also means the sawtooth is perfectly periodic in a way real traffic never is — one flow, one bottleneck, no cross traffic, no jitter. This is a model of the mechanism, not a network.
- A full tooth at 16×BDP takes 2 minutes 57 seconds. Reno adds one packet
per round trip, so climbing from a halved window back to a full one costs
cwnd/2round trips — and the round trips are themselves half a second long when the buffer is full. This is why both the demo and the sweep callprime()to start at the equilibrium, and why dragging the buffer slider re-primes rather than making you watch the climb. The readout prints the real period so the shortcut is visible rather than hidden. - The chart’s time window follows the sawtooth (2.5 teeth, clamped to 12–45 s) but the vertical ruler never moves. An axis that rescaled with the buffer would draw an identical picture at every setting, which is exactly the comparison being made — so 0–720 ms is fixed, shared with the sweep panel below, and a CoDel run is supposed to look like a chart with nothing in the top two thirds.
- Losses are noticed a round trip late, not instantly: a drop schedules a
notification at
now + propagation + current queue, which is roughly when dup ACKs would arrive. Reacting immediately makes the sawtooth’s teeth visibly sharper than a real one’s, and reacting to every packet of a burst collapses the window to the floor — hence one reduction per round trip, which is what fast recovery does in practice. - The p95 whiskers in the sweep matter more than the means. At 16×BDP the mean is 525 ms and p95 is 667 — a third of a second of spread, all of it invisible to a sender watching only for loss. Interactive traffic is judged on the whisker, not the dot.
- CoDel’s cost here is a single-flow artifact and is left in anyway. The honest version of “AQM is free” is “AQM is free once you have enough flows to keep the link busy through each one’s recovery”, and a viz with one flow in it should show the bill rather than quietly pick a friendlier scenario.
- The demo bundles its own copy of
standing.js(self-contained by contract); re-copy after editingsrc/, then re-runscripts/screenshot-demo.mjs.


