Mitigating Head-of-Line Blocking

When a single TCP packet is lost, every HTTP/2 stream on that connection waits — regardless of how important each resource is to rendering. This is transport-layer head-of-line (HOL) blocking, and it is the one failure mode that HTTP/2 & HTTP/3 multiplexing cannot solve through stream interleaving alone. This page explains why HOL blocking occurs at each protocol layer, how to configure HTTP/2 and HTTP/3 to minimise its impact, and how to measure whether your changes are working.


How HOL Blocking Works at Each Protocol Layer

HOL blocking is not a single problem — it appears at three distinct layers, and the fix at each layer is different.

Head-of-line blocking compared across HTTP/1.1 request queuing, HTTP/2 transport blocking, and HTTP/3 per-stream isolation Three columns. HTTP/1.1 shows one blocked request with two more queued behind it. HTTP/2 shows four streams on one TCP connection where a lost packet on stream 3 stalls streams 5 and 7. HTTP/3 shows the same four streams over QUIC, where only stream 3 retransmits and the others keep flowing. HTTP/1.1 Request queuing per connection Req A — BLOCKED (waiting) Req B — queued behind A Req C — queued behind A Opens up to 6 TCP connections to parallelize — each with its own TLS + TCP overhead. HTTP/2 Transport HOL on shared TCP Stream 1 — flowing Stream 3 — packet lost Stream 5 — STALLED Stream 7 — STALLED All streams freeze until TCP retransmits the lost segment. HTTP/3 / QUIC Per-stream loss isolation Stream 1 — flowing Stream 3 — retransmitting Stream 5 — still flowing Stream 7 — still flowing Loss on stream 3 does NOT pause other streams — QUIC retransmits independently. Lost packet Stalled / queued Still flowing

Layer 1 — Application HOL (HTTP/1.1)

HTTP/1.1 delivers responses in the order requests were issued on each TCP connection. Request B cannot be received until Request A completes, even if the server has B ready. Browsers open up to six parallel TCP connections per origin as a workaround, each carrying its own TLS handshake cost.

Layer 2 — Transport HOL (HTTP/2)

HTTP/2 multiplexes all streams over a single TCP connection, eliminating application-layer queuing. However, TCP guarantees in-order byte delivery. When a segment is lost, TCP’s retransmission mechanism stalls the receive buffer for every stream on the connection until the segment arrives. The streams are logically independent but physically serialised by the transport.

Layer 3 — Resolved by QUIC (HTTP/3)

QUIC (RFC 9000) reimplements reliable streams over UDP. Each QUIC stream has its own flow control and retransmission state. A lost packet on stream 3 triggers retransmission only for stream 3; streams 1, 5, and 7 continue delivering data. This per-stream isolation is why HTTP/3 can eliminate transport HOL blocking in a way HTTP/2 structurally cannot.

What the receiver is actually doing during a stall

The detail that makes transport HOL blocking counter-intuitive is that the data is usually already there. When a segment goes missing, the segments that follow it still arrive; Linux parks them in the out-of-order queue attached to the socket. The kernel knows exactly which bytes it holds and which one byte range is missing. It simply refuses to hand any of it to the application, because the socket API contract is a single ordered byte stream — recv() cannot return byte 40 000 while byte 32 000 is unknown.

The browser’s HTTP/2 layer sits on the other side of that contract. It has a frame parser ready, a DATA frame for stream 5 may be sitting complete in the out-of-order queue, and the decoder cannot see it. There is no API by which the network stack can say “give me whatever you have for these byte ranges”. This is why the fix cannot be implemented in the browser or the server: HOL blocking here is a property of the delivery interface, not of anyone’s code quality.

Two more things happen while the connection waits. First, the congestion controller reacts: classic CUBIC treats the drop as congestion and multiplies the window by 0.7, so the connection also comes back slower than it went in. Second, the receive window can fill — if the out-of-order queue grows past the advertised window, the sender is throttled even though the path is fine. On a 120 ms round trip with a 1 MB in-flight allowance, both effects together turn a single dropped 1460-byte segment into several hundred milliseconds of measurable page-load damage.

The cost of one loss event, in numbers

Work the arithmetic once and the priority becomes obvious. A 1.8 MB page delivered in 1460-byte segments is roughly 1 230 packets. At 1 % uniform loss that is about 12 loss events per page load; at 2 % it is closer to 25. Each event costs at least one round trip of detection and repair — 120 ms on a typical mobile path — and under TCP that cost is paid by every stream on the connection at once, because they all sit behind the same hole. Recovery episodes overlap, so the total is not a naive 12 × 120 ms, but the measured figure on that lab page is 1 480 ms of stalled delivery at 2 % loss versus 520 ms for the same page over QUIC. The mechanism-by-mechanism breakdown of why those two numbers differ — detection thresholds, acknowledgement ranges, RTT sampling, and the delivery contract itself — is worked through in TCP vs QUIC loss recovery under packet loss.

The diagram below traces a single one of those events packet by packet, with the same 120 ms path.

Packet-by-packet timeline of one loss event on a 120 millisecond path, comparing TCP delivery with QUIC delivery Ten 1460-byte packets arrive between 40 and 76 milliseconds; packet 5 is dropped. Under TCP, packets 6 to 10 are held in the kernel receive queue until the retransmission lands at 188 milliseconds, so every stream waits 132 milliseconds. Under QUIC the same packets are handed up on arrival because each STREAM frame carries a stream id and offset, and only stream 3 waits. One loss event on a 120 ms path: ten 1460 B packets, packet 5 dropped Arrival time in ms below each packet; the repair lands 132 ms after the hole appears HTTP/2 over TCP — the kernel delivers one ordered byte stream 1 40 2 44 3 48 4 52 5 dropped 6 60 7 64 8 68 9 72 10 76 retx 5 188 Packets 6-10 (7.3 KB) sit in the out-of-order queue: streams 1, 5 and 7 receive nothing for 132 ms. HTTP/3 over QUIC — every STREAM frame carries a stream id and offset 1 s1 2 s3 3 s5 4 s7 5 s3 lost 6 s1 7 s5 8 s7 9 s1 10 s5 retx 5 188 Streams 1, 5 and 7 are handed up as they land; only stream 3 waits for the repair. TCP receive queue at t = 100 ms 0 KB handed to the renderer; four streams waiting on one missing 1460 B segment. QUIC reassembly at t = 100 ms 7.3 KB handed up on streams 1, 5 and 7; only stream 3 waits for the retransmit.

Read the two rows against each other and the asymmetry is clear: the stall length is identical — 132 ms in both cases, because detection and repair cost a round trip under either transport. What changes is how many streams pay it. TCP multiplies one loss by the number of streams in flight; QUIC does not. That is the entire practical difference, and it is why the benefit grows with both the loss rate and the number of concurrent requests on the page.


Browser and Engine Differences

The table below summarises how each browser engine handles HOL blocking at the protocol negotiation layer.

Browser engine HTTP/2 HOL behaviour HTTP/3 negotiation QUIC fallback trigger
Chromium (V8) Full transport HOL — all streams stall on TCP loss ALPN h3 via Alt-Svc or HTTPS DNS record UDP blocked, handshake timeout, or an enterprise policy disabling QUIC
WebKit (Safari) Same TCP transport HOL Alt-Svc + h3 DNS HTTPS record (Safari 14+) Falls back within ~100 ms of QUIC failure; no user-visible flag
Gecko (Firefox) Same TCP transport HOL Alt-Svc with h3 token; enabled by default since Firefox 88 network.http.http3.enabled = false in about:config

All three engines perform 0-RTT QUIC resumption on repeat visits when the server supports it. First-visit QUIC requires 1-RTT, which adds one additional round-trip compared to a TLS 1.3 TCP handshake — an important consideration for cold-cache performance on high-latency connections.

Three behavioural differences matter more than the table suggests once you start reading field data.

Chromium remembers failure, aggressively. When a QUIC attempt to an origin fails, Chromium marks that alternative service broken and backs off exponentially before trying again — minutes at first, then longer. A single bad deploy that breaks UDP/443 for ten minutes therefore suppresses HTTP/3 for that user population well past the fix. Chromium also races the two jobs on the first attempt: if a usable TCP connection is already available it will start the QUIC job with a short delay and take whichever completes first, so an origin whose UDP path is merely slow silently loses to TCP without ever registering as a failure. chrome://net-export traces and chrome://net-internals/#quic show both behaviours directly.

Firefox exposes the state, Safari does not. about:networking#http3 lists which origins Firefox currently considers HTTP/3-capable, which makes it the fastest way to confirm an Alt-Svc rollout is being honoured. Safari offers no equivalent surface; on iOS the practical test is a field measurement, not a browser panel. That asymmetry biases naive debugging towards Chromium and Firefox, and it is why the RUM signal below matters more than any local check.

0-RTT support is not uniform. Chromium and Firefox both send early data on resumption for safe methods; Safari enabled it later and is more conservative about which requests qualify. If your measured HTTP/3 win comes mostly from 0-RTT rather than from loss isolation, expect the Safari slice of your traffic to show a noticeably smaller improvement, and do not average the two populations together when you evaluate the rollout.


Spec and API Reference

Key attributes and directives

Directive / attribute Scope Effect on HOL risk
fetchpriority="high" HTML elements Dispatches resource before lower-priority peers, reducing exposure time to a stall
Priority: u=0 response header (RFC 9218) HTTP/2 + HTTP/3 Signals urgency to intermediaries and the browser’s network thread
SETTINGS_MAX_CONCURRENT_STREAMS HTTP/2 server Too low → browser opens extra TCP connections, fragmenting cwnd
Alt-Svc: h3=":443"; ma=86400 HTTP response header Advertises QUIC endpoint for next visit (cached for ma seconds)
quic_versions / --quic-version NGINX / Caddy / H2O Pins QUIC draft versions; mismatches silently fall back to TCP
initial_max_streams_bidi QUIC transport parameter Controls how many bidirectional QUIC streams are allowed before a NEW_CONNECTION_ID is needed
SETTINGS_QPACK_BLOCKED_STREAMS HTTP/3 endpoint Above 0, header decoding on one stream may wait on an insert carried by another
net.ipv4.tcp_recovery=1 (RACK-TLP) Linux origin / fallback path Detects loss on a time threshold instead of three duplicate ACKs, shortening TCP stalls
net.ipv4.tcp_congestion_control=bbr Linux origin Stops treating every drop as congestion, so the window does not collapse on lossy links

Browser support matrix

Feature Chrome Firefox Safari Edge
HTTP/2 multiplexing 41+ 36+ 9+ 14+
fetchpriority attribute 101+ 132+ 17.2+ 101+
RFC 9218 Priority header 101+ 132+ 17.2+ 101+
HTTP/3 / QUIC 87+ 88+ 14+ 87+
QUIC 0-RTT resumption 87+ 88+ 16+ 87+

Step-by-Step Implementation

Step 1 — Verify protocol negotiation

Open Chrome DevTools → Network → right-click the column header → enable Protocol. Confirm resources show h2 or h3. If you see http/1.1, investigate whether Alt-Svc is missing, ALPN negotiation is failing, or UDP/443 is blocked.

# Confirm ALPN negotiation and certificate SAN in one command
curl -v --http2 https://example.com/ 2>&1 | grep -E "ALPN|subjectAltName|Using HTTP"

# Confirm HTTP/3 advertisement in response headers
curl -sI https://example.com/ | grep -i alt-svc

Step 2 — Serve the Alt-Svc header for HTTP/3 upgrade

Browsers do not attempt QUIC until they see an Alt-Svc advertisement. Add it to every response:

# nginx — add to server block after enabling quic and http3 in listen directives
add_header Alt-Svc 'h3=":443"; ma=86400' always;
# ma=86400 caches the advertisement for 24 h so repeat visitors skip the TCP connection entirely

For Caddy, HTTP/3 and Alt-Svc are enabled automatically when the quic directive is present. For Cloudflare, enable HTTP/3 (with QUIC) in the Speed → Optimization panel; the edge-side knobs that decide how much of that upgrade survives contact with real networks are covered in CDN edge tuning for QUIC and HTTP/3.

Step 3 — Tune HTTP/2 stream limits

When SETTINGS_MAX_CONCURRENT_STREAMS is too low, Chrome opens a second TCP connection, splitting the congestion window and defeating the single-connection multiplexing benefit. Set the limit to 100–200:

http {
  # Allow up to 128 concurrent HTTP/2 streams per connection.
  # Values below 100 cause Chrome to open additional connections,
  # reintroducing TLS overhead and splitting the TCP congestion window.
  http2_max_concurrent_streams 128;

  # Retire connections after 1000 requests to prevent long-lived stale state.
  http2_max_requests         1000;

  keepalive_timeout          65;
  keepalive_requests         200;
}

Step 4 — Apply fetch priority to critical resources

HTTP/2 stream prioritization via RFC 7540 PRIORITY frames is deprecated in RFC 9113. Modern browsers use fetchpriority and RFC 9218 Priority response headers instead — the two models and how they map onto each other are compared in HTTP/2 priority trees vs the HTTP/3 Priority header. Apply them to resources most likely to be on the critical path when a transport stall occurs:

<!-- fetchpriority="high" ensures LCP image and critical CSS enter the wire
     before lower-priority resources, narrowing the stall window -->
<link rel="preload" href="/critical.css" as="style" fetchpriority="high">
<img src="/hero.webp" fetchpriority="high" loading="eager" alt="Hero image">

<!-- Defer non-critical scripts so they cannot consume stream slots
     that critical resources need during a congestion window recovery -->
<script src="/analytics.js" defer fetchpriority="low"></script>

Server response header for critical API payloads:

HTTP/2 200 OK
Priority: u=0
Content-Type: application/json
Cache-Control: max-age=300, stale-while-revalidate=60

Step 5 — Consolidate origins to maximise connection reuse

Connection coalescing lets the browser reuse a single HTTP/2 or HTTP/3 connection for multiple hostnames that share the same TLS certificate and IP. This reduces the number of independent TCP congestion windows that can each suffer independent stalls. Deploy a Subject Alternative Name (SAN) certificate covering all subdomains used for static assets:

# Verify that coalescing is possible: both hostnames must resolve to the same IP
# and the certificate must list both in its SAN extension
curl -v --http2 https://static.example.com/asset.js 2>&1 | grep -E "subjectAltName|Connected to"
curl -v --http2 https://api.example.com/data.json  2>&1 | grep -E "subjectAltName|Connected to"
# If both show the same IP and a shared SAN, Chrome will coalesce them.

Step 6 — Match the mitigation to the measured loss profile

None of the previous steps is universally worth its operational cost. The deciding input is the loss rate on the paths your users actually have, taken at the 75th percentile rather than the median — the median user on fibre never sees this problem, and the tail user on a congested mobile cell sees nothing else. Read it from the origin (ss -ti reports retrans and bytes_retrans per socket) or reproduce it in the lab with a netem sweep at 0.5 %, 1 %, 2 % and 5 %.

Decision tree selecting a head-of-line blocking mitigation from the measured p75 packet-loss rate The p75 field loss rate branches three ways: below 0.5 percent, tune HTTP/2 stream limits and coalescing; between 0.5 and 2 percent, advertise HTTP/3 with a warm HTTP/2 fallback; above 2 percent, pair HTTP/3 with BBR. The high-loss branch splits again on the observed HTTP/3 share in field data, separating a filtered UDP path from a clean one. Which mitigation the measured loss profile calls for p75 field packet loss from RUM, ss -ti, or a netem sweep Loss below 0.5 % Stay on HTTP/2. Set the stream limit to 100-200 and coalesce origins onto one SAN certificate. Loss 0.5 % to 2 % Advertise h3 with Alt-Svc ma=86400 and keep the h2 listener tuned as the fallback. Loss above 2 % HTTP/3 plus BBR on the TCP path. Expect 15-40 % LCP gain on the affected slice. How to read the input Retransmit rate per socket from the origin, or a lab sweep at 0.5, 1, 2 and 5 % uniform loss. Use the p75, not the median: the tail is the audience. h3 share below 70 % UDP/443 is filtered on that path: tune TCP — SACK and RACK-TLP on. h3 share 70 % up Path is clean. Watch nextHopProtocol for silent regressions.

The branch that surprises teams is the left one. Under 0.5 % loss there is very little stalled delivery to recover, so an HTTP/3 migration buys back tens of milliseconds while adding a second protocol stack to operate, monitor and debug. The same effort spent raising a stream limit from 32 to 128, or collapsing three asset hostnames onto one certificate, usually returns more — and it improves the HTTP/2 path that every first visit uses regardless.


Verification Workflow

DevTools waterfall validation

  1. Open Network in Chrome DevTools. Enable the Protocol and Connection ID columns.
  2. Load the page and filter by XHR/Fetch or All.
  3. A healthy HTTP/2 waterfall shows staggered Receive Data bars across streams sharing one Connection ID. A HOL stall appears as a flat region where multiple streams show simultaneous Stalled or Waiting (TTFB) gaps — all streams on the same connection pause together.
  4. With HTTP/3, an isolated stall on one stream should NOT produce a simultaneous gap in other streams — verify this by inducing loss with the Network conditions throttle set to a custom profile with packet loss enabled.
Stylised DevTools Network panel showing four HTTP/2 streams on one connection pausing in the same 132 millisecond window Five waterfall rows with Protocol and Connection ID columns. Four h2 rows on connection 42 receive data until 130 milliseconds, hold for 132 milliseconds, then resume together. A fifth row, an h3 font on connection 57, transfers continuously through the same window. The signature of a transport stall in the Network panel Name Protocol Conn ID 0 100 200 300 400 ms app.css h2 42 main.js h2 42 hero.webp h2 42 /api/user h2 42 font.woff2 h3 57 The tell: one Connection ID, one shared gap Four streams on connection 42 stop and restart inside the same 132 ms window. Server-side delay would stagger them instead. The control row font.woff2 rides connection 57 over h3 and never pauses.

The control row is what turns the observation into a diagnosis. A shared gap across one connection can also be produced by a server that pauses all responses at once — a saturated worker pool, for instance — so the useful comparison is against a request that is not on that connection. If the h3 row keeps transferring straight through the window while the four h2 rows sit flat, the pause belongs to the transport, not to the origin.

Reading a NetLog trace

DevTools shows the symptom; NetLog shows the cause. Capture with chrome://net-export (select Include raw bytes off, Include cookies off) while reproducing the stall, then load the JSON in the NetLog viewer bundled with Chromium. Two event families answer the question:

  • QUIC_SESSION_PACKET_LOST names the packet number and, in the surrounding QUIC_SESSION_STREAM_FRAME_* events, the stream ids that packet carried. Other stream ids continuing to log received frames during the gap is the per-stream isolation claim, in verifiable form.
  • On HTTP/2, look for a long silence between HTTP2_SESSION_RECV_DATA events on every stream of a session at once. Silence on one stream is a slow handler. Silence on all of them, ending simultaneously, is the transport.

PerformanceObserver RUM snippet

Track protocol distribution and transport latency in production to detect silent h3 → h2 fallback. This is the same signal the decision tree above consumes, and the collection details — sampling, cache exclusion, and the fields that go empty without Timing-Allow-Origin — are covered in collecting nextHopProtocol with Resource Timing:

// Observe resource timing to detect protocol downgrades and transport stalls.
// nextHopProtocol === 'h3' confirms QUIC; 'h2' means the browser fell back.
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const protocol       = entry.nextHopProtocol;         // 'h3', 'h2', or 'http/1.1'
    const transportMs    = entry.connectEnd - entry.connectStart;
    const ttfbMs         = entry.responseStart - entry.requestStart;

    // Flag unexpected fallbacks — h3-capable origins showing h2 may indicate
    // UDP/443 blockage or an Alt-Svc cache miss after a server restart.
    if (protocol !== 'h3' && entry.name.startsWith('https://example.com')) {
      console.warn('QUIC fallback detected for', entry.name, '— protocol:', protocol);
    }

    analytics.track('resource_timing', {
      url: entry.name,
      protocol,
      transport_ms: transportMs,
      ttfb_ms:      ttfbMs
    });
  }
});
observer.observe({ type: 'resource', buffered: true });

Aggregate the result as a protocol share per network type rather than as a single site-wide number; a 92 % site average can hide a corporate segment sitting at 5 %. The wider practice of turning these entries into a defensible before-and-after is set out in measuring protocol performance in the field.

Synthetic packet-loss test

WebPageTest’s Advanced → Custom tab accepts a packetLossRate parameter. Run the same URL at 0 %, 1 %, 2 %, and 5 % loss with h2 and h3 protocols forced via the CDN config. Compare median TTFB and LCP across the matrix. QUIC should show a progressively smaller degradation relative to HTTP/2 as loss increases.

Two controls make the matrix trustworthy. Apply the impairment on the server’s egress interface only, so both protocols meet the identical network, and undo it between runs — stacked queueing disciplines silently compound and turn a 120 ms baseline into 240 ms with no warning. And force the protocol per run rather than trusting negotiation: a blocked UDP path will otherwise hand you an HTTP/2 measurement neatly labelled h3.


Edge Cases and Gotchas

UDP/443 blocking on enterprise networks

Corporate proxies and some ISPs block or throttle UDP traffic on port 443, silently preventing QUIC from completing its handshake. Chromium detects this by timing out the QUIC connection attempt and falling back to HTTP/2. The Alt-Svc entry is preserved in cache, so the browser continues retrying QUIC on each new session. You can detect the fallback rate via the nextHopProtocol RUM snippet above. If the fallback rate exceeds 30 % of your traffic, consider whether the QUIC upgrade overhead is worth the added complexity for that segment.

QUIC 0-RTT replay risk

QUIC supports 0-RTT session resumption, which lets the client send application data in the first packet. This is a significant latency win on repeat visits, but 0-RTT data is replay-vulnerable: a network attacker can re-send the first packet to the server. Limit 0-RTT to idempotent GET requests — never use it for POST, DELETE, or any state-mutating endpoint. Most CDN implementations (Cloudflare, Fastly) enforce this restriction automatically, but verify with your edge provider.

Alt-Svc cache invalidation after server migration

When you change your server’s IP address or TLS certificate, browsers that cached your Alt-Svc header continue attempting QUIC to the old endpoint for the duration of the ma (max-age) value. If the new endpoint is not reachable via UDP/443, browsers stall for the connection timeout before falling back. Mitigate this by reducing ma to 3600 (one hour) in the days before a planned migration, then restoring it afterwards.

SETTINGS_MAX_CONCURRENT_STREAMS misconfiguration

If you set this below 100, Chrome opens a second HTTP/2 connection, negating the single-connection benefit and splitting the TCP congestion window. If you set it above 1000 without increasing the server’s worker_connections and file descriptor limit, you risk exhausting OS resources during traffic spikes. The practical sweet spot is 100–200 for most origins; benchmark under load before increasing.

HTTP/2 priority frame deprecation

RFC 9113 (HTTP/2 revision, 2022) deprecates the PRIORITY frame and the HEADERS frame priority field defined in RFC 7540. Servers that still parse and act on these frames will continue to work, but generating them from clients has no effect on Chromium-based browsers since Chrome 96. Use fetchpriority and the RFC 9218 Priority response header instead — these are the only mechanisms Chromium’s network stack actively respects.

QPACK re-introduces a smaller stall at the header layer

HTTP/3 removes the transport stall but keeps a header-compression one. QPACK stores repeated header values in a dynamic table maintained over a separate unidirectional encoder stream. If a response references a table entry whose insertion is still in flight, that response’s headers cannot be decoded until the insert arrives — a genuine, if narrow, head-of-line dependency between otherwise independent streams. The endpoint’s advertised SETTINGS_QPACK_BLOCKED_STREAMS value caps how many streams may be in that state at once; setting it to 0 forbids the situation entirely at the cost of a worse compression ratio, because the encoder must then reference only already-acknowledged entries. Most CDNs pick a small non-zero value. It is worth knowing about because it is the one remaining way an HTTP/3 trace can show two streams stalling together, and mistaking it for transport blocking sends the investigation to the wrong layer.

UDP path MTU black holes and middlebox rate limits

QUIC requires a path that can carry at least 1200-byte UDP datagrams, and it discovers a larger usable size by probing. Where a tunnel or VPN reduces the effective MTU and the network drops oversized datagrams without sending the corresponding ICMP message, probing stalls: the connection appears to establish and then delivers nothing at full size. The symptom in field data is a small population with an established h3 connection and an unusually long TTFB, not the clean fallback you get from a blocked port. Separately, some access networks apply per-flow UDP rate limits that TCP flows escape, which shows up as QUIC being slower than HTTP/2 on an otherwise clean path. Both cases argue for keeping the HTTP/2 listener warm and well tuned rather than treating it as a legacy path.

The origin pays CPU for QUIC

TCP offloads segmentation, checksums and often TLS records to the network card. QUIC runs its congestion control, loss detection and packet protection in user space, and every datagram crosses the kernel boundary. Without generic segmentation offload for UDP the cost per byte is materially higher, and a busy origin can find that enabling HTTP/3 trades network stalls for CPU saturation. Measure requests per core before and after, enable UDP segmentation offload where the kernel and driver support it, and prefer terminating QUIC at an edge that is already engineered for it if your origin is CPU-bound.


FAQ

Does HTTP/2 multiplexing eliminate head-of-line blocking?

No. HTTP/2 eliminates application-layer HOL blocking — the per-request queuing that HTTP/1.1 imposes on each connection — but TCP’s in-order delivery guarantee means that a single lost packet still freezes every stream on the connection until retransmission completes. HTTP/3 over QUIC is the only protocol that resolves this at the transport layer.

What packet-loss rate makes HTTP/3 noticeably faster than HTTP/2?

Benchmarks consistently show QUIC outperforming HTTP/2 at loss rates above 1–2 %. Below 0.5 % the advantage is marginal and can be offset by QUIC’s higher per-packet CPU cost. On lossy mobile or satellite connections (2–5 % loss) the improvement to LCP and TTFB typically ranges from 15–40 %.

What happens when UDP/443 is blocked by a corporate firewall?

The browser falls back to HTTP/2 over TCP after the QUIC handshake times out. The Alt-Svc advertisement remains in the browser’s cache, so it re-attempts QUIC on the next session. Users on affected networks do not lose connectivity — they simply lose the HOL-isolation benefit of QUIC.

How does SETTINGS_MAX_CONCURRENT_STREAMS interact with HOL blocking?

When the limit is too low, browsers open additional TCP connections to compensate, reintroducing TLS handshake overhead and splitting the congestion window across multiple connections. This increases the total number of independent HOL stall surfaces. Set the limit to 100–200 to allow sufficient parallelism within a single connection without exhausting server resources.

Can fetchpriority prevent HOL blocking stalls?

fetchpriority controls the browser’s internal dispatch order — it ensures critical resources enter the wire before lower-priority peers. This reduces the window of time during which a critical resource is exposed to a concurrent stall, but it cannot change TCP’s in-order delivery guarantee. fetchpriority and QUIC solve different problems and are complementary, not alternatives.

Does HTTP/3 have any head-of-line blocking left?

Two narrower forms survive. QPACK can make one stream’s headers wait for a dynamic-table insertion carried on another stream, bounded by SETTINGS_QPACK_BLOCKED_STREAMS. And because a single QUIC packet can carry STREAM frames belonging to several streams, losing that packet delays each of them — but only the bytes that were physically inside it, not the whole connection. Neither effect scales with the number of streams the way TCP’s does, which is why the measured stall time falls by roughly two thirds rather than to zero.

Should I keep HTTP/2 enabled once HTTP/3 works?

Yes, permanently. HTTP/3 is only reachable after a browser has seen an Alt-Svc advertisement or an HTTPS DNS record, so the very first contact from a new client lands on TCP. Every path with filtered UDP stays on TCP forever. Treat the HTTP/2 listener as a first-class production path: keep its stream limit tuned, keep SACK and RACK-TLP enabled on the origin, and keep it in your monitoring.

Does connection coalescing make head-of-line blocking worse?

It concentrates the exposure rather than increasing it. Putting more streams on one connection means one dropped packet stalls more of the page at once — but the alternative is several connections, each with its own small congestion window, its own handshake, and its own opportunity to stall. On HTTP/2 the trade is genuinely two-sided and worth measuring on your loss profile. On HTTP/3 it is one-sided: per-stream delivery removes the shared stall, so coalescing is simply a win.

Why did LCP not improve after I enabled HTTP/3?

Check three things in order. First, the loss rate: below 0.5 % there is almost nothing to isolate, so the protocol change cannot show up. Second, the adoption rate — segment your field data by nextHopProtocol and confirm the population you are measuring actually used h3; first visits without an HTTPS DNS record never do. Third, the bottleneck: if LCP is bound by server think time, a render-blocking stylesheet, or a late-discovered image, delivery was never the constraint and no transport will fix it.