Learning objective. Measure the end-to-end latency of an HTTP/3 GET and decompose it into
named components (handshake, time-to-first-byte, transfer) on a path with a known emulated
RTT, then cross-check the breakdown against the transport's own smoothed_rtt.
Making RTT visible on loopback #
Everything in this chapter is about round trips, but a loopback connection has an RTT near zero, so
the components collapse into noise. The lab fixes that by inserting a deterministic UDP delay
proxy between client and server: a tiny asyncio relay that forwards every datagram after a
fixed one-way delay (25 ms here, so RTT = 50 ms). It stays entirely on 127.0.0.1 and offline, but
the client now experiences a real, known round trip. That lets us line up each measured component
against the round trips §8.1 predicted.
The client marks four timestamps with a monotonic clock (perf_counter): t_start before
connecting, t_connected when wait_connected() returns (handshake ready), t_first_headers when
the response's HEADERS arrive (first byte), and t_done when the body's final STREAM data drains.
The differences are the components. Separately, it reads the last smoothed_rtt the client recorded
in its qlog metrics_updated events ([RFC 9002 §5.3]) as an independent measure of the path RTT.
Running it #
emulated path: one-way delay 25 ms -> RTT 50 ms
end-to-end latency components for one HTTP/3 GET:
QUIC + TLS handshake (connect ready) 59.8 ms ~1 RTT
request sent -> first response header 53.1 ms ~1 RTT (TTFB)
first header -> body complete 0.2 ms (transfer)
------------------------------------------------------
total 113.1 ms
transport's own estimate: smoothed_rtt = 54.1 ms (should track the emulated 50 ms)
check: handshake ~1 RTT and TTFB ~1 RTT -> a cold GET costs ~2 RTT before the body
arrives; 0-RTT would remove one of them.
Reading the breakdown #
The measured components confirm the RTT budget from §8.1, now in milliseconds on a 50 ms path:
- Handshake ≈ 60 ms ≈ 1 RTT. The combined QUIC+TLS handshake (§8.1) completes in a single round trip: ~50 ms of network plus a few milliseconds of crypto and scheduling. This is the round trip 0-RTT or a warm connection (§8.3) would remove.
- TTFB ≈ 53 ms ≈ 1 RTT. From sending the request to the first response header is one more round trip: the request travels to the server (25 ms), the server responds, and the headers return (25 ms). On a cold connection this stacks on the handshake, so first byte lands at ≈2 RTT.
- Transfer ≈ 0 ms. The 4 KB body fits in the response flight already in transit, so there is no additional round trip; it drains as fast as the headers. A body larger than the congestion window or a per-stream window would add transfer time here, which is where the concurrency and flow-control sizing of §8.4 shows up.
The independent check closes the loop: the transport's smoothed_rtt of 54 ms tracks the emulated
50 ms (the small excess is the ACK-delay and scheduling that §8.2 described, mostly
removed by the ack_delay subtraction but never to zero). Two numbers derived completely
differently (wall-clock component timing and the protocol's EWMA estimator) agree on the path,
which is what tells you the measurement is sound.
Turning components into decisions #
The value of a breakdown is that each component points to a different fix:
| Dominant component | Likely cause | Where to look |
|---|---|---|
| Handshake | cold connection every time | reuse/pool connections, enable 0-RTT (§8.3) |
| Handshake, inflated | extra Retry or amplification stall | address validation, small cert chain (§8.3) |
| TTFB beyond 1 RTT | server processing, or a request waterfall | server timing; batch requests (§8.4) |
| Transfer | body exceeds window / congestion | flow-control and concurrency sizing (§8.4, §7.2) |
smoothed_rtt ≫ min_rtt |
queueing, ACK delay, path change | ACK delay, stale min_rtt (§8.2) |
A single "the page is slow" measurement is not actionable; the same total split into these components tells you whether to change your connection strategy, your request structure, or your window sizes.
The absolute milliseconds vary a few ms between runs: wall-clock timing includes OS scheduling and
the event loop, which the transport's estimator smooths out. What is stable, and what matters, is
the structure: handshake ≈ 1 RTT, TTFB ≈ 1 RTT, transfer ≈ 0 for a small body, and smoothed_rtt
≈ the path RTT. Read component ratios and RTT counts, not the third decimal place.
editorial Build this breakdown into your own clients and dashboards.
Client-side timing marks (connection ready, first byte, complete) plus the transport's smoothed_rtt
and min_rtt from qlog give you a component view that a single latency number can't. And the delay
proxy here is a genuinely useful test harness for reproducing high-RTT behavior deterministically in
CI, without a real slow network. When a latency regression lands, the first question is always
which component grew, because that alone narrows the fix from "everything" to one of the rows above.
Takeaways #
Measuring latency means decomposing it: a monotonic clock at four points splits an HTTP/3 GET into
handshake (~1 RTT), TTFB (~1 RTT), and transfer (~0 for a small body), and the transport's
smoothed_rtt independently confirms the path RTT. Each component maps to a distinct remedy, so the
breakdown — not the total — is what drives a fix. A deterministic UDP delay proxy makes all of this
reproducible offline. This closes Chapter 8; the chapter quiz checks the model, and
Chapter 9 turns to the tightest latency regime of all — real-time traffic.