§8.5
Chapter 08 · Handling High-Latency Networks and Long RTT Effects

§8.5Practical Example: Measuring End to End Latency Components

RFC 9002Runnable lab

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.

ServerDelay proxy (+25ms each way)Client (timers)t_startt_connected —handshakecomponent ≈ 1 RTTt_first_headers —TTFB component ≈1 RTTt_done — transfercomponent (bodydrains)components = handshake + TTFB + transfer · cross-check vs qlogsmoothed_rttInitial (ClientHello)(after 25 ms)ServerHello, cert(after 25 ms)request HEADERS (GET)(after 25 ms)response HEADERS + DATA(after 25 ms)
Fig. 8.5-1The measurement setup. A delay proxy adds 25 ms each way (50 ms RTT). The client stamps a monotonic clock at four points — start, handshake ready, first response header, body complete — so the gaps between them are the handshake, TTFB, and transfer components. The transport's own smoothed_rtt (from the client qlog) provides an independent check.RFC 9002 §5.3

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 #

captured output — uv run --with aioquic==1.3.0 labs/08-5-latency-components/latency_components.pyTEXT
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.

Note

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.

In practice

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.