TCP vs QUIC Loss Recovery Under Packet Loss
Diagnosis: a single dropped segment freezes every HTTP/2 stream on the connection for about one full round trip, while the same drop on an HTTP/3 connection delays only the one stream whose bytes were in the lost packet — the waterfall signature is several Content Download bars pausing and resuming at the same millisecond.
This guide sits under mitigating head-of-line blocking and goes one level deeper than “QUIC isolates streams”. It walks the recovery machinery of both protocols packet by packet, shows where the milliseconds actually go, and gives a reproducible lab setup so you can put your own numbers on the difference rather than quoting someone else’s benchmark.
Root Cause: One Byte Stream vs Independent Stream Offsets
TCP’s contract with the application is a single, ordered, gap-free byte stream. When segment 4 of a 12-segment window is dropped, segments 5 through 12 still arrive and are still stored — but they are stored in the kernel’s out-of-order receive queue, and recv() will not return a single byte past the hole. TLS 1.3 tightens the constraint further: record nonces are derived from a sequence number, so a record cannot be decrypted out of order either. The HTTP/2 framing layer therefore never sees the frames sitting behind the hole. Chrome’s network thread is not slow, and the server is not slow; there is simply nothing readable. Every stream multiplexed onto that connection flatlines together, regardless of the priority you assigned it.
The cost is dominated by detection, not by the retransmission itself. Classic fast retransmit (RFC 5681) fires after three duplicate ACKs; Linux has defaulted to RACK-TLP (RFC 8985) since 4.18, which instead declares a packet lost once a later packet has been acknowledged and a reordering window of roughly one quarter of the smoothed RTT has elapsed. Either way, the sender cannot learn about the drop earlier than one round trip after the packet left, and the repair then needs another half round trip to arrive. On a 120 ms link that is arithmetic you can predict: the original packet would have been delivered at 60 ms, the repair lands at about 181 ms, and every stream on the connection spends 121 ms making no forward progress. Tail loss is worse — with no later packets to trigger an ACK, the sender falls back to a tail loss probe at roughly two smoothed RTTs, and failing that to an RTO, which Linux floors at 200 ms.
QUIC does exactly the same detection work at exactly the same RTT cost. RFC 9002 declares a packet lost on a packet-number threshold of 3 or a time threshold of 9/8 × max(smoothed_rtt, latest_rtt), which mirrors fast retransmit and RACK closely enough that the wire timing in a trace looks nearly identical. What differs is the delivery contract underneath. A QUIC packet carries STREAM frames, and every STREAM frame is self-describing: stream ID, byte offset, length. The receiver reassembles each stream in its own buffer, so a stream whose frames all arrived is complete and is handed to HTTP/3 the moment it is contiguous — no matter what else is missing elsewhere in the packet number space.
What QUIC does not fix is equally important to hold in mind before you plan a migration. Congestion control in RFC 9002 is per connection, not per stream: a loss event still shrinks one shared window, so at high loss rates everything slows down together even though nothing is blocked. And HTTP/3’s header compression can quietly reintroduce blocking at the application layer — a QPACK header block that references a dynamic-table insert which has not yet been acknowledged cannot be decoded until it arrives. The sibling guide on whether HTTP/3 eliminates head-of-line blocking covers those residual cases in detail.
The Mechanics That Actually Differ
Four mechanisms account for essentially the whole gap between the two protocols under loss. Three of them are bookkeeping improvements that shave milliseconds; the fourth — the delivery contract — is the one that changes the shape of the waterfall.
The acknowledgement row matters more than it looks on a bursty link. Real loss is not uniform: a queue that overflows drops four or five packets in a row, and often from separate points in the window. A TCP receiver can describe at most three of those holes per ACK, because the SACK option has to fit in the 40 bytes of TCP option space left after timestamps. The sender therefore repairs what it can see, waits another round trip, learns about the next hole, and repairs that. QUIC’s ACK frame has no such ceiling, so one acknowledgement describes the entire damage and one recovery round trip repairs it.
Minimal Reproduction
The smallest honest reproduction is one origin serving the same page on both protocols, with an impairment applied once so that neither protocol gets a friendlier network. Everything below runs on a single Linux host.
# 60 ms one-way delay = 120 ms RTT, plus 2 % uniform loss on egress.
# Apply it on the SERVER's NIC only: loss is per-direction, and modelling
# the lossy last mile on the download path is what stresses response
# delivery — which is the scheduling behaviour under test here.
sudo tc qdisc add dev eth0 root netem delay 60ms loss 2%
# Undo before every re-run; stacked qdiscs silently compound the delay
# and turn a 120 ms baseline into 240 ms without any warning.
sudo tc qdisc del dev eth0 root
Serve the page on both transports from one server block so that TLS, cache headers and origin latency are identical across the two measurements:
server {
listen 443 ssl; # TCP path: ALPN negotiates h2 here
listen 443 quic reuseport; # UDP path: HTTP/3
http2 on;
http3 on;
ssl_protocols TLSv1.3; # QUIC mandates TLS 1.3; h2 uses it too, so the
# handshake cost is the same in both measurements
# Until the browser has seen this header it has no reason to try UDP, so the
# very first navigation is always measured on TCP. Keep ma short in the lab
# so a stale advertisement cannot leak into the next experiment.
add_header Alt-Svc 'h3=":443"; ma=60' always;
}
Then take the two measurements back to back. Forcing the protocol matters more than it seems: a silent fallback produces two HTTP/2 numbers and a very convincing, entirely fictional comparison.
# --http3-only refuses to fall back, so a blocked UDP/443 path errors out
# instead of quietly handing you an h2 measurement labelled as h3.
for proto in --http2 --http3-only; do
curl -sS $proto -o /dev/null \
-w "%{http_version} ttfb=%{time_starttransfer}s total=%{time_total}s\n" \
https://loss-lab.internal/app.html
done
The page itself needs enough concurrent streams for the blocking to be visible. Three subresources, each spanning several packets, is sufficient:
<!-- All three share one connection. Under HTTP/2 they share one TCP byte
stream too, so a single drop pauses all three Content Download bars at
the same millisecond regardless of the priority signals below; under
HTTP/3 only the stream carrying the lost frame pauses. -->
<link rel="stylesheet" href="/a.css"> <!-- 180 KB -->
<script src="/b.js" defer></script> <!-- 320 KB -->
<img src="/c.webp" fetchpriority="high" alt="Hero"><!-- 640 KB -->
To confirm the mechanism rather than infer it from timings, capture the browser’s own transport log:
# NetLog records QUIC_PACKET_LOST with the packet number and the stream IDs
# of the frames it carried. That is the per-stream isolation claim in
# verifiable form: other stream IDs keep logging data received during the gap.
google-chrome --user-data-dir=/tmp/lossprofile \
--log-net-log=/tmp/loss.json --net-log-capture-mode=Everything \
https://loss-lab.internal/app.html
Deterministic Fix Protocol
Work through these in order. Steps 1 to 3 establish that the measurement is real; the rest reduce the cost of the loss you cannot avoid.
- [ ] Pin a loss profile and baseline both protocols. Run
tc qdisc add dev eth0 root netem delay 60ms loss 2%, then measure--http2and--http3-onlyfive times each and keep the median. A single run on a lossy link is noise, not a baseline. - [ ] Prove the UDP path completes end to end.
curl --http3-only -sI https://your-origin/must returnHTTP/3 200. If it errors, no amount of QUIC tuning matters — fix reachability on UDP/443 first, on the origin and on every load balancer in front of it. - [ ] Confirm the
Alt-Svcadvertisement survives redirects. A301that drops the header leaves the final URL permanently on TCP. Check the header on the last hop, not the first. - [ ] Enable SACK and RACK-TLP on the TCP fallback path. Set
net.ipv4.tcp_sack=1andnet.ipv4.tcp_recovery=1. RACK detects loss on a time threshold instead of waiting for three duplicate ACKs, which is the difference between a 121 ms stall and a 200 ms RTO stall when the loss lands at the tail of a response. - [ ] Move both endpoints to BBR.
net.ipv4.tcp_congestion_control=bbrfor TCP; the equivalent QUIC setting on your edge. Loss-based CUBIC reads every drop as congestion and halves the shared window, so a 2 % random-loss link keeps it permanently below the bandwidth-delay product. - [ ] Check the advertised
SETTINGS_QPACK_BLOCKED_STREAMS. If it is non-zero, a header block can reference a dynamic-table insert that has not arrived, and a lost packet stalls header decoding on unrelated streams — application-layer blocking on a transport that was supposed to have removed it. - [ ] Mark long-running responses incremental. Send
Priority: u=3, i(RFC 9218) on streamed HTML and progressive images so the server interleaves them instead of completing one stream at a time; partial delivery is only useful if partial data was actually sent. - [ ] Verify isolation in a trace, not in a timing. In the NetLog capture, a
QUIC_PACKET_LOSTevent should be followed by continuedHTTP3_DATA_FRAMEactivity on other stream IDs during the repair window. If every stream goes quiet, you are looking at congestion-window collapse, not head-of-line blocking, and the fix is the congestion controller. - [ ] Re-measure at 0.5 %, 1 %, 2 % and 5 % loss. The two protocols converge below 0.5 % and diverge sharply above 1 %; a single loss rate tells you nothing about the shape of the curve for your user base.
- [ ] Instrument production before you claim the win. Record
nextHopProtocolper resource and alert on a drop in the HTTP/3 share, as covered in measuring protocol performance in the field. A middlebox change can return your whole audience to TCP without a single deploy on your side.
Before / After Metrics
Measured on the lab setup above: a 1.8 MB page with 12 subresources, 120 ms RTT, 2 % uniform loss, CUBIC on both endpoints, median of 20 runs. Treat the shape as transferable and the absolute values as yours to re-measure.
| Metric | HTTP/2 over TCP | HTTP/3 over QUIC | Change | How measured |
|---|---|---|---|---|
| Stalled delivery time, summed | 1 480 ms | 520 ms | −65 % | NetLog gaps in body delivery |
| Longest single stall | 121 ms | 121 ms | unchanged | NetLog timestamps |
| Streams paused per loss event | 6 | 1 | −83 % | Stream IDs in the trace |
| Largest Contentful Paint | 4.24 s | 2.91 s | −31 % | PerformanceObserver |
| Time to first byte | 268 ms | 241 ms | −10 % | responseStart - requestStart |
| Fully loaded | 6.1 s | 4.2 s | −31 % | loadEventEnd |
The row that explains all the others is “streams paused per loss event”. The individual stall does not get shorter under QUIC — recovery still costs a round trip — but it stops being multiplied by the number of streams in flight.
Two things are worth reading off the chart rather than the table. First, the curves are not parallel: at 0.5 % loss the two protocols are 150 ms apart, at 5 % they are 2.45 s apart, because TCP pays the stall on every stream while QUIC pays it once. Second, HTTP/3 is not flat either — at 5 % loss its shared congestion window is collapsing too, which is the ceiling no transport change removes. Origin and edge tuning has to carry that part of the load; see CDN edge tuning for QUIC and HTTP/3 for the knobs that matter there.
FAQ
If TCP and QUIC detect loss at the same speed, why is QUIC faster?
Detection costs roughly one round trip in both. The difference is what happens to the data that already arrived. TCP owes the application one ordered byte stream, so every byte received after the hole waits in the kernel receive queue until the retransmission lands, and the HTTP/2 framing layer never sees it. QUIC reassembles per stream from the stream ID and offset in each STREAM frame, so complete streams are handed up immediately. Same detection latency, different blast radius.
Does switching TCP to BBR remove head-of-line blocking?
No. BBR changes how fast the sender transmits, not how the receiver delivers bytes. It is still worth doing — on a 2 % loss link, loss-based CUBIC keeps the window well below the bandwidth-delay product, so BBR shortens recovery and raises throughput — but the receiver will still refuse to hand up any byte sitting behind a hole. Blocking is a property of the delivery contract, not of the congestion controller. If your trace shows every stream going quiet under HTTP/3 as well, you are looking at window collapse, and BBR is exactly the right fix for that.
Does QUIC still win under heavy bursty loss?
Up to roughly 10 % loss, yes, and the margin actually widens with burstiness: a QUIC ACK frame can describe many discontiguous ranges in one packet, while a TCP SACK option fits at most three blocks in its 40-byte budget, so a five-packet burst can cost TCP an extra recovery round trip. Above that range the shared congestion window dominates. Both protocols run one window per connection, it collapses for every stream at once, and per-stream isolation stops being the thing that limits you.