Bufferbloat: a bigger buffer buys latency, not throughput
Simulate a modelNeeds Python
Problem
A queue between a producer and a slower consumer absorbs bursts, but it cannot make the consumer faster. When the producer is faster for a long time, the queue fills and stays full, and every item then waits for the whole queue ahead of it: a bigger buffer buys latency, not throughput. By Little's law the wait is the queue length divided by the service rate. Dropping items that have already waited too long (they are useless to whoever was waiting) keeps the work the consumer does useful.
Simulate a producer at 1,200 items/s and a consumer at 1,000 items/s with a bounded queue. Run it with queue sizes 100, 1,000, 10,000 and 100,000. Record completed items per second and the p50 and p99 time each item spent in the queue. Then add deadline-aware dropping (drop an item at dequeue if it waited more than 1 second) and compare goodput, meaning items that were served within 1 second.
The script is an exact discrete-event simulation, run long enough for the largest queue to fill.
The question. As the queue grows from 100 to 100,000, what happens to throughput, to the time an item waits, and to goodput, with and without deadline dropping?
Predict
Expect throughput to stay at about 1,000/s for every size, while latency grows in proportion to the queue size (a full queue of 10,000 at 1,000/s means about 10 seconds of waiting).
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
bufferbloat-a-bigger-buffer-buys-latency.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Bufferbloat: a bigger buffer buys latency, not throughput. A producer at 1,200 items/s and a consumer at 1,000 items/s with a bounded queue (full: drop the newest). An exact discrete-event simulation, so the timings are not blurred by thread scheduling. Then deadline-aware dropping: skip items that waited more than 1 s, and count goodput. """ import argparse, json from collections import deque p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--produce", type=float, default=1200, help="Items produced per second.") p.add_argument("--consume", type=float, default=1000, help="Items the consumer serves per second.") p.add_argument("--seconds", type=float, default=1500, help="Simulated seconds (long enough for the largest queue to fill).") p.add_argument("--sizes", type=lambda s: [int(v) for v in s.split(",")], default=[100, 1000, 10000, 100000], help="Queue sizes.") p.add_argument("--deadline", type=float, default=1.0, help="Seconds after which an item is stale.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() def run(qsize, drop_stale): q, waits, good, dropped_full, dropped_stale = deque(), [], 0, 0, 0 next_arrival, next_service, t = 0.0, 0.0, 0.0 while t < args.seconds: if next_arrival <= next_service: t = next_arrival if len(q) < qsize: q.append(t) else: dropped_full += 1 next_arrival += 1 / args.produce else: t = next_service while q: w = t - q.popleft() if drop_stale and w > args.deadline: dropped_stale += 1; continue waits.append(w); good += w <= args.deadline break next_service += 1 / args.consume waits = sorted(waits[len(waits) // 2:]) # steady state: the second half of the run pct = lambda x: waits[min(len(waits) - 1, int(len(waits) * x))] if waits else 0 return {"queue": qsize, "deadline_drop": "yes" if drop_stale else "no", "served_per_s": len(waits) / (args.seconds / 2), "p50_s": pct(0.5), "p99_s": pct(0.99), "goodput_per_s": sum(w <= args.deadline for w in waits) / (args.seconds / 2), "dropped_stale": dropped_stale} rows = [] for drop in (False, True): for n in args.sizes: r = run(n, drop); rows.append(r) print(f"queue {n:>7,} deadline drop {r['deadline_drop']:<3} served {r['served_per_s']:6.0f}/s p50 {r['p50_s']:7.3f} s " f"p99 {r['p99_s']:7.3f} s goodput (<= {args.deadline:g} s) {r['goodput_per_s']:6.0f}/s") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"runs": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run bufferbloat-a-bigger-buffer-buys-latency.py uv run bufferbloat-a-bigger-buffer-buys-latency.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --produce1200 | Items produced per second. |
| --consume1000 | Items the consumer serves per second. |
| --seconds1500 | Simulated seconds (long enough for the largest queue to fill). |
| --sizes100, 1000, 10,000, 100,000 | Queue sizes. |
| --deadline1 | Seconds after which an item is stale. |
Our run
Data table
| Queue size | series | p99 wait (s) |
|---|---|---|
| 100 | No deadline | 0.1 |
| 1K | No deadline | 1 |
| 10K | No deadline | 10 |
| 100K | No deadline | 100 |
| 100 | Drop items older than 1 s | 0.1 |
| 1K | Drop items older than 1 s | 1 |
| 10K | Drop items older than 1 s | 1 |
| 100K | Drop items older than 1 s | 1 |
Steady state
| Queue | Deadline drop | Served / s | p99 wait | Served within 1 s / s |
|---|---|---|---|---|
| 100 | no | 1,000 | 100 ms | 1,000 |
| 1,000 | no | 1,000 | 1,000 ms | 1,000 |
| 10K | no | 1,000 | 10 s | 0 |
| 100K | no | 1,000 | 100 s | 0 |
| 100 | yes | 1,000 | 100 ms | 1,000 |
| 1,000 | yes | 1,000 | 1,000 ms | 1,000 |
| 10K | yes | 1,000 | 1,000 ms | 1,000 |
| 100K | yes | 1,000 | 1,000 ms | 1,000 |
queue 100 deadline drop no served 1000/s p50 0.100 s p99 0.100 s goodput (<= 1 s) 1000/s
queue 1,000 deadline drop no served 1000/s p50 1.000 s p99 1.000 s goodput (<= 1 s) 1000/s
queue 10,000 deadline drop no served 1000/s p50 10.000 s p99 10.000 s goodput (<= 1 s) 0/s
queue 100,000 deadline drop no served 1000/s p50 100.000 s p99 100.000 s goodput (<= 1 s) 0/s
queue 100 deadline drop yes served 1000/s p50 0.100 s p99 0.100 s goodput (<= 1 s) 1000/s
queue 1,000 deadline drop yes served 1000/s p50 1.000 s p99 1.000 s goodput (<= 1 s) 1000/s
queue 10,000 deadline drop yes served 1000/s p50 1.000 s p99 1.000 s goodput (<= 1 s) 1000/s
queue 100,000 deadline drop yes served 1000/s p50 1.000 s p99 1.000 s goodput (<= 1 s) 1000/sWhat we found
Throughput stayed at 1,000 items per second at every queue size, as expected, while the p99 wait grew with the queue: 100 ms at 100, 10 s at 10,000 and 100 s at 100,000. Once a full queue held more than a second of work, goodput fell to 0 items per second served in time. Dropping stale items at dequeue restored it to 1,000 at every size.
Read it
(Python timers are coarse, so the absolute rates will be a little off. Look at the trend, not the exact numbers.) The script avoids that by simulating time exactly.
Size a buffer by the latency you can afford, not by the burst you want to absorb: buffer = acceptable wait × service rate. Beyond that, a buffer only converts overload into delay, and the right response to sustained overload is to shed load or push back on the producer.