Skip to content
About
Main content

Bufferbloat: a bigger buffer buys latency, not throughput

Simulate a modelNeeds Python

Problem

A between a and a slower 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 , not . By the wait is the queue length divided by the . 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 , 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

With a full 10,000-item queue and a 1-second deadline, how many items per second are served in time?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    bufferbloat-a-bigger-buffer-buys-latency.py59 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

Time in the queue (p99) by queue sizeApple M2 · 8 cores · 8 GB
No deadlineDrop items older than 1 s
Data table
Time in the queue (p99) by queue size
Queue sizeseriesp99 wait (s)
100No deadline0.1
1KNo deadline1
10KNo deadline10
100KNo deadline100
100Drop items older than 1 s0.1
1KDrop items older than 1 s1
10KDrop items older than 1 s1
100KDrop items older than 1 s1
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

Steady state

Steady state
QueueDeadline dropServed / sp99 waitServed within 1 s / s
100no1,000100 ms1,000
1,000no1,0001,000 ms1,000
10Kno1,00010 s0
100Kno1,000100 s0
100yes1,000100 ms1,000
1,000yes1,0001,000 ms1,000
10Kyes1,0001,000 ms1,000
100Kyes1,0001,000 ms1,000
Output · uv run bufferbloat-a-bigger-buffer-buys-latency.py
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/s

What 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.