Jordy Nguyen

Latency lab

2026

  • Python
  • TCP handshake RTT
  • tshark
  • pcap

Role: sole author · Status: phases 1 and 2 complete and published; phases 3 onward are not being worked · Measured: 1 Sep 2026, on my own residential line

I’ve played competitive FPS at a high level for seven years, so I know what a link feels like when it degrades. This project exists to stop describing that and start measuring it.

The result is the reason the rest of this site is built the way it is.


The headline

Latency to 1.1.1.1, measured as TCP handshake round-trip, in three conditions:

Conditionp50p95p99maxthroughput
Idle15.6 ms31.0 ms34.4 ms47.2 ms
Download saturated34.5 ms530.6 ms1038.8 ms1045.4 ms457 Mbit/s
Upload saturated11.3 ms30.6 ms31.3 ms31.9 ms42 Mbit/s

Download load multiplies p99 latency by thirty — 34 ms to 1039 ms. One probe in twenty waits longer than half a second. Upload load adds nothing at all, and is fractionally better than idle, which is inside the noise.

That asymmetry is the whole finding, and it is the opposite of what most home-network advice assumes.

Why the average hides it

The mean under download load was 93.1 ms with a standard deviation of 215.5 ms. A speed test reporting “93 ms average” reads as mediocre but survivable. It conceals a distribution whose tail reaches a full second.

Nobody notices the median packet. Everyone notices the one that took 1,038 ms. That gap is the entire distance between “the link looks fine” and “the game feels awful while someone’s downloading.”

Measuring the handshake, not the ping

The probe is a TCP handshake, not ICMP. Routers routinely deprioritise or rate-limit ICMP, so ping can report a healthy line while real traffic queues behind a full buffer. A SYN/SYN-ACK round trip crosses the same queues the real data does.

  • Probes are sequential — one handshake, then a 100 ms gap.
  • Load is generated in each direction separately: 8 concurrent streams, 40 s per phase, with a 4 s ramp before sampling so TCP reaches steady state and the queue actually fills.
  • Throughput is recorded during every load phase, because a latency number without the load that produced it means nothing.
  • Python standard library only, no admin rights.

A consequence worth stating, because it biases a number in my own favour: since probes are sequential, the sample rate falls as latency rises — 317 samples idle against 190 under download load. The loaded median is therefore computed from fewer, and slower, samples than the idle one.

Where the queue actually is

Phase 2 re-ran the measurement per hop, idle and under a 448 Mbit/s download:

HopIdle p50Loaded p50Loaded p95
1 · router (LAN)1 ms1 ms2 ms
3 · ISP edge11 ms77 ms340 ms
9 · ISP core13 ms109 ms508 ms
12 · internet14 ms111 ms509 ms

My own router does not move. It holds at a 1 ms median under the same load that pushes the end-to-end p99 past a second. The step change happens at the first hop outside my house: the ISP edge goes from 11 ms to 77 ms, with a p95 of 340 ms.

The buffer is in the carrier’s access equipment, not on my LAN. That distinction is the difference between a problem I can fix with a qdisc and a problem I cannot fix at all — and it is exactly the question that “my internet feels laggy” can never answer on its own.

Hop 2 never replied to a probe, in either run. It is omitted from the table rather than silently folded into the neighbouring hops.

What is still weak

Phase 2b sampled loss under saturation with a packet capture: 11.8% loss, p99 1173.1 ms.

That run is the weakest evidence here and I am not going to dress it up:

  • 15 samples. Far too few to put a confidence interval around.
  • The offered load was not recorded during that capture, so the loss figure has no throughput to sit against — the same mistake the phase 1 harness was built specifically to avoid.

It is a signal worth following, not a result. Phases 3 onward — reproducing this against the hardware path, then testing whether an AQM at my own edge does anything about a queue that is demonstrably one hop further out — are not being worked. Phase 1 and phase 2 are what exist.

The part that connects to everything else

The fix for this class of problem is not more bandwidth. It is a queue discipline that refuses to let a buffer fill.

That claim is testable, and I tested it in a place where I do control the queue: netlab lab 19 takes a saturated link and swaps the leaf qdisc from a FIFO to fq_codel. Loaded p99 goes from 976 ms to 21 ms — a 46× reduction, at a 0.3% throughput cost.

Same mechanism, same measurement discipline, on a link where the buffer is mine to change.


Measured on a single residential connection at one location, on one evening. Everything above is one line’s behaviour, not a claim about a carrier or a region.

All work