Reproduce incast on a laptop
Measure your machineNeeds Python
Problem
Incast is a many-to-one traffic burst: a request fans out to many servers, they all answer at once, and their replies converge on one switch port whose buffer overflows. TCP loses packets, and a sender that loses the tail of a short reply waits for its retransmission timeout, typically 200 ms or more, so the tail latency jumps by orders of magnitude at the point where the burst first overflows the buffer, instead of rising smoothly. Jittering the replies spreads the burst and moves the knee.
Send N concurrent requests for a 32 KB reply to one process; increase N from 1 to 500; plot p50 / p99 / p999 latency. Then repeat with jittered sending (each reply delayed by 0 to 2 ms at random). The test runs on loopback, which has no switch buffer to overflow.
The question. Does the tail latency jump at some N, and does jitter move it?
Predict
You should see a knee, and p999 should jump by orders of magnitude instead of rising smoothly (it is an RTO timer, not queueing). With jittered sending the knee moves.
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
reproduce-incast-on-a-laptop.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Reproduce incast on a laptop. One receiver asks N senders (here: N connections to a server process) for a 32 KB response at the same instant, and waits for all of them, like a scatter-gather query. N grows from 1 to 500; p50, p99 and p999 of the per-response latency are recorded. Then the same with jittered sending: each sender waits a random 0-2 ms before answering. On loopback there is no switch buffer to overflow, so the drop-and-RTO behaviour of real incast may not appear: the run records what loopback does. """ import argparse, asyncio, json, resource, subprocess, sys, time p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--senders", default="1,10,50,100,200,300,500", help="Values of N.") p.add_argument("--rounds", type=int, default=40, help="Queries per N.") p.add_argument("--kb", type=int, default=32, help="Response size (KB).") p.add_argument("--jitter-ms", type=float, default=2.0, help="Maximum jitter in the jittered run (ms).") p.add_argument("--port", type=int, default=58401, help="Local port.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) resource.setrlimit(resource.RLIMIT_NOFILE, (min(hard, 8192), hard)) SERVER = r""" import asyncio, random, resource, sys soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE); resource.setrlimit(resource.RLIMIT_NOFILE, (min(hard, 8192), hard)) port, size, jitter = int(sys.argv[1]), int(sys.argv[2]) * 1024, float(sys.argv[3]) / 1000 body = b"x" * size async def h(r, w): while await r.readline(): if jitter: await asyncio.sleep(random.uniform(0, jitter)) w.write(body); await w.drain() async def main(): s = await asyncio.start_server(h, "127.0.0.1", port, backlog=4096); print("ready", flush=True) async with s: await s.serve_forever() asyncio.run(main()) """ async def query_rounds(n): conns = [await asyncio.open_connection("127.0.0.1", args.port) for _ in range(n)] lat = []; size = args.kb * 1024 for _ in range(args.rounds): t0 = time.perf_counter() for r, w in conns: w.write(b"go\n") async def read(r): await r.readexactly(size); return time.perf_counter() - t0 lat += await asyncio.gather(*(read(r) for r, _ in conns)) for _, w in conns: w.close() return lat def pct(xs, q): xs = sorted(xs); return xs[min(len(xs) - 1, int(q * len(xs)))] rows = [] for jitter in (0.0, args.jitter_ms): srv = subprocess.Popen([sys.executable, "-c", SERVER, str(args.port), str(args.kb), str(jitter)], stdout=subprocess.PIPE, text=True) srv.stdout.readline() try: for n in map(int, args.senders.split(",")): lat = asyncio.run(query_rounds(n)) rows.append({"sending": "jittered" if jitter else "synchronized", "N": n, "p50_ms": pct(lat, .5) * 1000, "p99_ms": pct(lat, .99) * 1000, "p999_ms": pct(lat, .999) * 1000, "max_ms": max(lat) * 1000}) finally: srv.kill(); srv.wait() for r in rows: print(f"{r['sending']:<13} N={r['N']:>4} p50 {r['p50_ms']:8.2f} ms p99 {r['p99_ms']:8.2f} ms p999 {r['p999_ms']:8.2f} ms") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"latency": 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 reproduce-incast-on-a-laptop.py uv run reproduce-incast-on-a-laptop.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --senders1, 10, 50, 100, 200, 300, 500 | Values of N. |
| --rounds40 | Queries per N. |
| --kb32 | Response size (KB). |
| --jitter-ms2 | Maximum jitter in the jittered run (ms). |
| --port58,401 | Local port. |
Our run
synchronized
jittered
Data table
| sending | Senders N | p50 | p99 | p999 |
|---|---|---|---|---|
| synchronized | 1 | 0.136 | 0.216 | 0.216 |
| synchronized | 10 | 0.174 | 0.318 | 0.328 |
| synchronized | 50 | 0.451 | 1.1 | 1.25 |
| synchronized | 100 | 0.876 | 1.93 | 2.2 |
| synchronized | 200 | 1.91 | 4.41 | 4.72 |
| synchronized | 300 | 2.88 | 4.54 | 4.86 |
| synchronized | 500 | 5.62 | 9.52 | 10.1 |
| jittered | 1 | 1.18 | 2.29 | 2.29 |
| jittered | 10 | 1.22 | 2.13 | 2.17 |
| jittered | 50 | 1.28 | 2.48 | 2.6 |
| jittered | 100 | 1.47 | 2.59 | 2.8 |
| jittered | 200 | 2.09 | 4.28 | 4.49 |
| jittered | 300 | 3.28 | 6.09 | 6.35 |
| jittered | 500 | 5.64 | 9.6 | 10.3 |
synchronized N= 1 p50 0.14 ms p99 0.22 ms p999 0.22 ms
synchronized N= 10 p50 0.17 ms p99 0.32 ms p999 0.33 ms
synchronized N= 50 p50 0.45 ms p99 1.10 ms p999 1.25 ms
synchronized N= 100 p50 0.88 ms p99 1.93 ms p999 2.20 ms
synchronized N= 200 p50 1.91 ms p99 4.41 ms p999 4.72 ms
synchronized N= 300 p50 2.88 ms p99 4.54 ms p999 4.86 ms
synchronized N= 500 p50 5.62 ms p99 9.52 ms p999 10.10 ms
jittered N= 1 p50 1.18 ms p99 2.29 ms p999 2.29 ms
jittered N= 10 p50 1.22 ms p99 2.13 ms p999 2.17 ms
jittered N= 50 p50 1.28 ms p99 2.48 ms p999 2.60 ms
jittered N= 100 p50 1.47 ms p99 2.59 ms p999 2.80 ms
jittered N= 200 p50 2.09 ms p99 4.28 ms p999 4.49 ms
jittered N= 300 p50 3.28 ms p99 6.09 ms p999 6.35 ms
jittered N= 500 p50 5.64 ms p99 9.60 ms p999 10.33 msWhat we found
There was no knee: p999 rose smoothly with N, from 0.216 ms with one sender to 10.1 ms with 500, the shape of a queue, not of a timeout. Jitter changed the low end (adding its own delay) and not the high end (10.3 ms at 500). This lab did not reproduce incast: loopback has no switch buffer and does not drop packets, so TCP never waits for a retransmission.
Read it
Incast needs the thing loopback lacks: a shallow buffer shared by many senders, where packets are dropped. On a laptop the closest you can get is a network namespace with a rate-limited, small-queue link (tc with tbf or netem), which needs Linux and root. What this run does show is the baseline: without drops, many-to-one latency grows linearly with the burst.