HTTP/2 & HTTP/3 Multiplexing and Connection Optimization
Modern transport protocols do far more than carry bytes from server to browser. HTTP/2 and HTTP/3 reshape the entire request lifecycle — replacing sequential queues with multiplexed streams, shifting transport logic from the kernel into user space, and giving engineers explicit control over bandwidth allocation at the frame level. This section covers the full stack: binary framing, HTTP/2 stream prioritization, QUIC-based transport, head-of-line blocking elimination, connection coalescing, and CDN edge tuning for QUIC and HTTP/3. The intended audience is frontend engineers and performance teams managing high-traffic properties where protocol misconfigurations translate directly into degraded Largest Contentful Paint (LCP) and Time to First Byte (TTFB).
Two habits separate teams that get real gains from teams that only get a new protocol label in their logs. The first is treating the connection as a scheduler rather than a pipe: once every request shares one connection, the only thing standing between the LCP image and a tag-manager bundle is the order in which the server emits frames. The second is measuring the change where it lands. A protocol upgrade that only 30% of navigations reach is a 30% experiment, and averaging it against the other 70% hides the effect entirely — measuring protocol performance in the field covers how to split real-user timings by the protocol that actually served them.
Architecture Overview: How the Browser Uses Multiplexed Transport
Before configuring anything, it helps to trace exactly what happens when a browser opens a connection and begins multiplexing requests.
The key insight is that multiplexing happens after a single connection is established. Every stream — HTML, CSS, images, scripts — shares the same TCP/QUIC pipe. The server’s and browser’s ability to schedule those streams intelligently determines whether critical CSS arrives before the render-unblocking threshold or gets starved behind a low-priority analytics payload.
Two details in that sequence are worth dwelling on. First, ALPN selection is a property of the handshake, not of the URL: a browser that has never spoken to the origin has no way to know HTTP/3 exists there unless a DNS HTTPS record advertises it, so the first navigation of a session is negotiated over TCP and the QUIC benefit begins on the visit after. Second, the SETTINGS exchange is not a handshake round trip — both peers may send frames immediately after their own SETTINGS without waiting for the acknowledgement. A server that defers its first response bytes until it has received SETTINGS ACK throws away a full round trip for nothing, and that mistake shows up in the waterfall as a fixed, suspiciously round TTFB penalty on every cold connection.
Binary Framing and the Life of a Stream
Everything above the transport is frames. Understanding their shape explains why some tuning knobs move the needle and others cannot.
The frame is the unit of scheduling
An HTTP/2 frame carries a nine-octet header: a 24-bit length, an 8-bit type, an 8-bit flags field, and a 31-bit stream identifier. The types that matter operationally are HEADERS (a compressed header block), DATA (body bytes), SETTINGS, WINDOW_UPDATE, RST_STREAM, PRIORITY_UPDATE (the RFC 9218 replacement for PRIORITY), and GOAWAY. HTTP/3 keeps the same conceptual frames but drops the ones QUIC already provides: flow control and stream resets are transport-level features there, so there is no WINDOW_UPDATE frame and no RST_STREAM frame — QUIC’s MAX_STREAM_DATA and RESET_STREAM do the job one layer down.
Because SETTINGS_MAX_FRAME_SIZE defaults to 16,384 bytes, a 900 KB JavaScript bundle is emitted as at least 55 DATA frames. That granularity is precisely what makes interleaving possible: the server’s scheduler gets to make a fresh decision at every frame boundary, so a high-urgency stream that becomes ready halfway through a large low-urgency transfer only waits for the current frame to drain, not for the whole response. Raising MAX_FRAME_SIZE to reduce per-frame overhead therefore coarsens the scheduler. On a connection carrying a mix of urgencies, 16 KB frames are almost always the right answer; larger frames only pay off on single-stream bulk transfer.
Stream identifiers and their arithmetic
Client-initiated HTTP/2 streams are odd-numbered and server-initiated ones even; stream 0 is reserved for connection-level frames such as SETTINGS and connection WINDOW_UPDATE. Identifiers only ever increase and are never reused, which is why GOAWAY carries the last stream ID the server actually processed: everything above that number is safe for the client to retry on a fresh connection. A very long-lived connection can in principle exhaust the 2³¹ identifier space, at which point the endpoint must open a new connection — a real consideration for a WebSocket-style long poll over HTTP/2, not for page loads.
HTTP/3 encodes more into the identifier. The low two bits carry the initiator and the directionality, so stream 0 is the first client-initiated bidirectional stream, and unidirectional streams carry the control stream, the QPACK encoder stream and the QPACK decoder stream. This matters when reading a qlog trace or chrome://net-internals: an HTTP/3 request does not live on “stream 1” the way its HTTP/2 counterpart does, and mapping a request to a stream ID requires reading the stream type byte first.
That reset path is not an exotic error case — it is a routine part of page loading. A browser cancels streams when an image scrolls out of relevance, when a fetch is aborted by an AbortController, when a <script> is removed before it loads, and on every same-document navigation that abandons in-flight requests. Each cancellation costs one RST_STREAM frame and frees the concurrency slot immediately, which is normally exactly what you want.
The complication is that servers now police it. The Rapid Reset denial-of-service technique abused the fact that a client can open and immediately reset streams faster than the server can free them, so mainstream servers and CDNs added reset-rate limiters that respond to an excessive number of resets on one connection with a GOAWAY and a connection close. A carousel or infinite-scroll implementation that opens and cancels dozens of image requests per second can trip that limiter on a strict configuration; the symptom is a page that abruptly re-handshakes mid-scroll, visible as a fresh TLS segment in the waterfall for requests that should have been multiplexed. If you see it, cancel less aggressively — prefer loading="lazy" and an intersection threshold over speculative fetch-then-abort — before you raise the server’s limit.
Header compression: HPACK versus QPACK
HTTP/2 compresses headers with HPACK, which maintains a dynamic table (4,096 bytes by default) shared by both peers. Because the table’s state depends on every previous header block, HPACK requires strictly ordered delivery — which TCP guarantees. QUIC does not guarantee ordering across streams, so HTTP/3 uses QPACK, which moves table updates onto dedicated unidirectional encoder and decoder streams and lets a request reference table entries that may not have arrived yet.
That deferred reference is the one place HTTP/3 can still block a stream on another stream’s data. The SETTINGS_QPACK_BLOCKED_STREAMS value caps how many requests may be waiting on a not-yet-received table update at once. Setting it to 0 forbids the encoder from making any such reference: header blocks get slightly larger because repeated values must be re-sent as literals or resolved against the static table only, but no request can ever wait on QPACK state. A value around 16 with the default 4,096-byte dynamic table is a reasonable middle ground for a page with a few dozen requests carrying long cookies; if your headers are small, 0 costs almost nothing and removes a whole class of stall.
Protocol Limits and Priority Reference
Before tuning anything, anchor your work against the hard limits the specifications impose.
HTTP/2 Protocol Limits
| Parameter | Default | Spec maximum | Where to tune |
|---|---|---|---|
INITIAL_WINDOW_SIZE (stream) |
65,535 bytes | 2³¹ − 1 (2 GB) | SETTINGS frame |
INITIAL_WINDOW_SIZE (connection) |
65,535 bytes | 2³¹ − 1 (2 GB) | WINDOW_UPDATE |
MAX_CONCURRENT_STREAMS |
unlimited | server-defined | Nginx: http2_max_concurrent_streams |
MAX_FRAME_SIZE |
16,384 bytes | 16,777,215 bytes | SETTINGS |
MAX_HEADER_LIST_SIZE |
unlimited | implementation-defined | SETTINGS |
| Stream weight (RFC 7540, deprecated) | 16 | 1–256 | PRIORITY frame |
| Urgency level (RFC 9218) | 3 | u=0 (highest) – u=7 (lowest) | Priority: header |
Note the asymmetry hiding in the first two rows: each peer advertises the window it is willing to receive, so the setting that governs a download is the browser’s, and the setting that governs an upload is the server’s. Both directions have a per-stream window and a separate connection-level window, and a transfer stalls the moment either one reaches zero.
RFC 9218 Priority Urgency Levels
RFC 9218 replaces the RFC 7540 weight/dependency tree. Modern browsers and servers use the Priority: header with urgency (u) and incremental (i) parameters.
| Urgency | Typical resource type | Incremental | Browser default |
|---|---|---|---|
u=0 |
Render-blocking CSS in <head> |
i=0 |
Chromium: CSS parser-blocking |
u=1 |
Synchronous fonts, critical scripts | i=0 |
Chromium: sync <script> |
u=2 |
Preloaded fonts, high-priority XHR | i=0 |
Chromium: high-priority fetch |
u=3 |
LCP image, above-fold media | i=1 |
Chromium: image in viewport |
u=4 |
Prefetched resources | i=1 |
Chromium: prefetch |
u=5 |
Below-fold images, deferred scripts | i=1 |
Chromium: low-priority fetch |
u=6 |
Favicon, non-critical fonts | i=1 |
Chromium: lowest |
u=7 |
Speculation rules, background sync | i=1 |
Chromium: idle |
The i=1 (incremental) flag signals that the server may interleave partial DATA frames from this stream between other streams, enabling progressive rendering. i=0 means the server should complete the response before sending other streams at the same urgency.
That distinction is more consequential than the urgency number for image-heavy pages. Five non-incremental images at the same urgency are delivered one after another, so the first one completes early; five incremental images at that urgency are round-robined, so all five finish at roughly the same late moment. For a gallery below the fold, round-robin is fine. For five candidate LCP images in a hero carousel, it is the difference between one decodable image at 900 ms and five at 1.4 s. The comparison between the old dependency-tree model and this header-based one is worked through in HTTP/2 priority trees vs the HTTP/3 Priority header.
QUIC / HTTP/3 Connection Parameters
| Parameter | Recommended value | Notes |
|---|---|---|
| UDP MTU | 1,200–1,350 bytes | Avoids fragmentation; QUIC blocks it at IP layer |
| 0-RTT | Enabled for GET only | Replay risk on non-idempotent methods |
initial_max_data |
10 MB | Connection-level flow control |
initial_max_stream_data_bidi_local |
1 MB | Per-stream window |
| Alt-Svc cache TTL | 86400 s | Controls how long browsers remember h3 availability |
| Connection migration | Enabled | Critical for mobile network switching |
Critical Path Analysis
Not all connections are equally urgent. These are the configurations that block rendering and the thresholds where each becomes a problem.
Render-Blocking Connection Scenarios
TLS handshake on a cold connection. When the browser encounters a cross-origin resource with no warm connection, TLS 1.3 adds 1 RTT before the first byte can arrive. At 50 ms RTT (typical mobile), that is 50 ms of render-blocking latency for the first resource on that origin. Use <link rel="preconnect"> for third-party origins that serve render-critical assets.
MAX_CONCURRENT_STREAMS exhaustion. If the server caps concurrent streams at a value lower than the number of render-blocking resources on the page, additional requests queue inside the browser rather than being multiplexed. The browser cannot proceed until an existing stream completes and a slot opens. Monitor DevTools → Network → Protocol column for queued requests with h2 protocol. The safe minimum for most pages is MAX_CONCURRENT_STREAMS = 100. The queue is invisible in the response timings themselves — it shows up only as inflated Queueing and Stalled time, which is why diagnosing request queueing and stalled time is the first place to look when TTFB is fine but the waterfall is not.
Flow control window starvation. The initial 64 KB connection window is frequently exhausted before critical CSS and LCP images arrive. When the window fills, DATA frames stall until the receiver sends a WINDOW_UPDATE. For pages with more than ~60 KB of render-critical content, negotiate a larger initial window (256 KB–1 MB) in SETTINGS to prevent this stall. This is the most common invisible bottleneck in HTTP/2 performance problems.
The arithmetic generalises. The number of stalls is ceil(bytes / window) - 1, and each one costs a full round trip, so the penalty is (ceil(bytes / window) - 1) × RTT. At a 64 KB window and 50 ms RTT, a 1 MB video poster pays 750 ms of pure waiting; at a 1 MB window it pays none. What saves most page loads today is that Chromium and Firefox both raise their receive windows to multi-megabyte values with a WINDOW_UPDATE sent immediately after the connection preface, so the browser-facing download path rarely stalls in practice. The default bites in the two directions people forget: uploads, where the origin is the receiver and most servers never raise the 64 KB default, and proxy-to-origin hops, where a reverse proxy or CDN shield speaks HTTP/2 to the origin with library defaults. A large form POST or file upload that mysteriously plateaus at roughly window / RTT bytes per second is this bug and nothing else.
QUIC blocked by UDP throttling. Corporate firewalls and some mobile carrier NATs drop UDP traffic on port 443. When QUIC fails, Alt-Svc fallback to HTTP/2 requires a new TCP handshake, adding 1–2 RTTs. Ensure your server monitors the QUIC handshake success rate; if it falls below 90%, the overhead of failed QUIC attempts may exceed the benefit. Chromium hedges this by racing a QUIC attempt against a TCP attempt on a fresh connection and taking whichever completes first, so the worst case is bounded — but the racing itself costs sockets and battery, and it only applies where the browser already has an Alt-Svc entry.
Safe-to-Defer Configurations
The following settings affect non-critical latency and can be tuned progressively without render-blocking risk:
- Server Push /
103 Early Hints:103 Early Hintsis safe to deploy or remove without affecting render-blocking resources directly, as long as the hinted resources are already in the preload chain. The trade-offs of each are compared in HTTP/2 Server Push vs 103 Early Hints. - QUIC 0-RTT: Disabling 0-RTT for safety adds 1 RTT per new connection but does not affect the critical path on warm connections.
keepalive_timeout: Values between 60 s and 300 s are operationally equivalent for most traffic patterns; tuning this is a memory-vs-reuse trade-off, not a render-critical one.MAX_FRAME_SIZE: Leaving it at the 16 KB default is right for nearly every page; changing it is a bulk-transfer optimisation and slightly degrades scheduling granularity.
Implementation Patterns
Pattern 1: Nginx HTTP/2 and HTTP/3 with Tuned Window Sizes
server {
listen 443 ssl;
listen 443 quic reuseport; # HTTP/3 over QUIC
http2 on;
# Advertise HTTP/3 via Alt-Svc so browsers upgrade on next visit
add_header Alt-Svc 'h3=":443"; ma=86400';
# Increase initial connection window to 1 MB (default: 64 KB)
# Prevents WINDOW_UPDATE stalls for pages with > 64 KB of render-critical assets
http2_chunk_size 16k;
http2_max_concurrent_streams 128;
http2_idle_timeout 300s;
# Keep connections alive long enough to amortize TLS handshake cost
keepalive_timeout 300s;
keepalive_requests 10000;
# TLS 1.3 only: reduces cold-connection handshake from 2-RTT to 1-RTT
ssl_protocols TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# ALPN: advertise h2 and h3 — the client selects based on capability
# No manual ALPN string needed with modern Nginx; http2 on + quic handles it
}
Pattern 2: 103 Early Hints for Render-Critical Resources
Server Push is deprecated. Use 103 Early Hints to pre-warm the browser’s preload scanner while your origin processes the request. The browser receives the 103 informational response and immediately starts fetching the hinted resources, before the 200 body arrives.
location = / {
# Emit 103 Early Hints before upstream responds
# The browser starts fetching these in parallel with origin processing
add_header Link '</styles/critical.css>; rel=preload; as=style';
add_header Link '</fonts/inter-var.woff2>; rel=preload; as=font; crossorigin';
add_header Link '</images/hero.avif>; rel=preload; as=image; fetchpriority=high';
# 103 requires upstream support or a caching layer that emits the 103
# Cloudflare and Fastly both support this natively
proxy_pass http://upstream;
}
The win is bounded by your origin’s think time: if the 200 follows the 103 within 20 ms there is nothing to overlap, and the hint costs a few hundred bytes for nothing. The pattern pays when origin processing is 100 ms or more, which is exactly the case for personalised, uncacheable HTML. The full deployment path across edge and origin is covered in 103 Early Hints implementation.
Pattern 3: Explicit Priority Headers for Critical Resources
RFC 9218 Priority headers let you override the browser’s heuristic defaults, which is important when the browser cannot determine viewport visibility at parse time.
<!-- LCP image: urgency 3, incremental (server may interleave partial frames) -->
<img
src="/images/hero.avif"
fetchpriority="high"
alt="Product hero image"
width="1200" height="630"
>
<!-- Critical CSS: urgency 0 by default for parser-blocking stylesheets.
If moved to async load, restore urgency explicitly via Priority header -->
<link rel="stylesheet" href="/styles/critical.css">
<!-- Below-fold image: suppress urgency so it does not compete with LCP -->
<img
src="/images/below-fold.webp"
loading="lazy"
fetchpriority="low"
alt="Feature screenshot"
width="800" height="450"
>
On the server, you can reinforce these hints with explicit Priority response headers:
HTTP/2 200 OK
Content-Type: image/avif
Priority: u=3, i=1
Cache-Control: public, max-age=31536000, immutable
A response Priority header is advisory in the other direction: it tells the client what the server thinks, and a client that has already scheduled the request is free to ignore it. The authoritative signal is the request-side header, or a PRIORITY_UPDATE frame sent after the request when the browser changes its mind — which Chromium does, for instance, when an image that was below the fold at request time scrolls into view.
Pattern 4: Restricting 0-RTT to Requests That Can Survive a Replay
0-RTT is the single largest latency win QUIC offers on repeat visits, and the single easiest way to create a duplicate-order bug. The rule to encode at the edge is not “GET is safe” but “this request is safe to execute twice”.
server {
listen 443 quic reuseport;
ssl_early_data on; # allow 0-RTT
location / {
# Non-idempotent methods must never execute from early data.
# 425 tells the client to retry the request after the handshake completes.
if ($request_method !~ ^(GET|HEAD)$) {
set $reject_early $ssl_early_data;
}
if ($reject_early) { return 425; }
# Pass the flag upstream so the application can make its own decision
# for endpoints that are GET but still mutate state (logout links, counters).
proxy_set_header Early-Data $ssl_early_data;
proxy_pass http://upstream;
}
}
The branch most teams miss is the middle one: a GET that mutates state. Logout links, “mark as read” endpoints, view counters and one-time download tokens are all GET in plenty of codebases, and all of them misbehave when executed twice. Passing an Early-Data header upstream lets the application answer 425 Too Early for exactly those routes instead of forcing a blanket policy. The QUIC 0-RTT session resumption gotchas guide walks through the ticket-lifetime and anti-replay-window details this snippet glosses over.
Browser Engine Differences
The protocol is a specification; the scheduling is an implementation. Three engines make materially different choices, and a fix validated in one can be a no-op in another.
Chromium. Implements RFC 9218 end to end: it sends the Priority request header, emits PRIORITY_UPDATE frames when a resource’s urgency changes after the request was issued, and ignores RFC 7540 dependency trees entirely. Its DevTools Priority column shows the internal five-level scale (Highest, High, Medium, Low, Lowest) rather than the wire urgency, so u=0 through u=2 all display as Highest. Chromium also races QUIC against TCP on connections where Alt-Svc is known, and partitions its socket pools by top-level site, so the same origin embedded on two different parent sites does not share a connection.
Firefox. Historically the most enthusiastic user of the RFC 7540 dependency tree: it creates idle “anchor” streams representing leader groups (followers, urgent-start, background) and hangs real streams off them. Over HTTP/3 it uses the Priority header instead. Its coalescing check is stricter than Chromium’s — it verifies both certificate coverage and that the second name actually resolves to the connected address, so a wildcard certificate alone will not cause coalescing.
WebKit/Safari. Supports HTTP/3 and sends priority signals, but exposes far less of the machinery: there is no per-request priority column in the Web Inspector network table, so the only way to confirm a priority change took effect is to watch the arrival order in the waterfall or read it server-side. fetchpriority support arrived later than in Chromium, so a page tuned only with that attribute may schedule differently on older iOS versions still in the field. Safari is also the engine where an unexpected connection can hide, because its handling of coalescing across a shared IP differs from both other engines.
The practical consequence is that any priority change should be validated on at least Chromium and one non-Chromium engine before it is called done. A deeper side-by-side is in Chrome vs Safari vs Firefox: priority differences, and the browser-side scheduling model that feeds all of it is covered in core browser loading mechanics and priority queues.
Diagnostics and Tooling
Chrome DevTools — Network Panel
- Open DevTools → Network tab. Right-click the column header row and enable Protocol and Priority.
- Reload with cache disabled (Shift+Reload).
- Filter by
h3orh2in the search bar to verify ALPN negotiation succeeded. - Look for requests showing Stalled or Queued timing segments. These indicate
MAX_CONCURRENT_STREAMSexhaustion or flow control window saturation. - Click any resource → Timing tab → verify
QUICorHTTP/2handshake completes before Waiting (TTFB). - For the LCP candidate: confirm Priority column shows
Highest(Chromium mapsu=0–u=2toHighest,u=3toHigh, etc.). - Enable the Connection ID column. Requests sharing an ID share a connection; a second ID for the same host is a coalescing failure or a socket-pool partition, and verifying connection coalescing with DevTools separates the two cases.
chrome://net-internals
Navigate to chrome://net-internals/#quic to inspect active QUIC sessions, packet loss rates, and connection migration events. chrome://net-internals/#http2 shows active HTTP/2 sessions, stream counts, and WINDOW_UPDATE frame timing — the essential signal for diagnosing flow control stalls.
For a permanent record rather than a live view, capture a qlog trace. Chromium writes one per QUIC session with --log-net-log, and the resulting event stream contains every packet, every stream frame and every congestion-window change — enough to reconstruct exactly which stream was starved and why. This is heavy artillery, but it is the only tool that answers “which frame was on the wire at the moment the render stalled”.
WebPageTest
In WebPageTest, use the Connection View to see protocol negotiation timing per origin. The Waterfall view color-codes requests by connection; requests that share a color are multiplexed on the same connection. Cross-origin requests that open new connections show a distinct color break and a new TLS handshake segment.
Enable Capture Network Log to export a HAR file, then import it into Chrome DevTools for frame-level analysis.
Lighthouse
Lighthouse flags protocol-related issues under Performance:
- “Uses HTTP/2” audit (deprecated in Lighthouse 10+, replaced by protocol checks in the network waterfall).
- “Eliminate render-blocking resources” — indirectly caused by stream priority inversion or flow control stalls.
- “Avoid multiple page redirects” — each redirect resets the connection state and may require a new TLS handshake.
Run Lighthouse with --throttling-method=devtools and --emulated-form-factor=mobile to surface latency that does not appear on fast connections.
Field measurement
Every tool above describes one load on one machine. Protocol work is unusually badly served by that, because the variable you changed — which transport a visitor actually got — is decided by the visitor’s network, not by you. PerformanceResourceTiming.nextHopProtocol reports the negotiated protocol per request, Server-Timing splits the server-side half of TTFB, and an Alt-Svc holdback turns the comparison into a real randomised arm instead of a self-selected one. The full collection and analysis workflow, including sample-size budgets and the traps that make a QUIC rollout look free when it is not, is in measuring protocol performance in the field.
Common Failure Modes
Priority Inversion Under Multiplexing
Priority inversion occurs when a low-urgency stream (u=5–u=7) receives bandwidth allocation before a high-urgency stream (u=0–u=2). This happens when:
- The server does not implement RFC 9218 scheduling and uses a naive round-robin over streams.
- A CDN terminates HTTP/3 at the edge but forwards to origin over HTTP/1.1, discarding priority metadata.
- The browser’s
fetchpriorityattribute is applied to a resource that the preload scanner already fetched at a higher urgency, and the late attribute change has no effect.
Diagnosis: in the DevTools Network waterfall, if a render-blocking resource (CSS, sync script) starts downloading noticeably later than below-fold images that were requested at the same time, priority inversion is the likely cause. See HTTP/2 stream prioritization for the full diagnostic workflow.
Head-of-Line Blocking at the TCP Layer
HTTP/2 eliminates application-layer HOL blocking but retains TCP-layer HOL blocking: a single lost TCP packet stalls all streams on the connection until the packet is retransmitted and the receiver’s buffer drains in order. On lossy networks (packet loss > 1%), HTTP/2’s single-connection architecture can perform worse than HTTP/1.1’s six-connection approach, because one loss event blocks every stream simultaneously.
HTTP/3’s QUIC transport eliminates this by implementing per-stream loss recovery in user space. A lost UDP packet only stalls the stream it belongs to. The tradeoff is that QUIC’s user-space stack consumes ~15–25% more CPU per connection than the kernel TCP stack. See mitigating head-of-line blocking for the protocol-level mechanics and measurement approach, and TCP vs QUIC loss recovery under packet loss for how each stack detects and repairs the loss.
Connection Coalescing Gone Wrong
When multiple hostnames resolve to the same IP address and share a TLS certificate (e.g., a wildcard cert), HTTP/2 browsers may coalesce requests onto a single connection. This is usually beneficial — it avoids a new TLS handshake. But if the coalesced connection’s MAX_CONCURRENT_STREAMS limit is reached, requests that would have opened a separate connection instead queue behind existing streams. Connection coalescing and domain sharding covers how to verify whether coalescing is occurring and how to prevent unintended coalescing by using distinct IP addresses or certificates.
Alt-Svc Rollout Latency
HTTP/3 requires an existing HTTP/2 or HTTP/1.1 connection to advertise the Alt-Svc: h3=":443" header before the browser will attempt QUIC on the next navigation. First-time visitors always use HTTP/2 or HTTP/1.1. The upgrade to HTTP/3 happens on the second visit (or if a DNS HTTPS record is configured). Monitor the share of traffic arriving over h3 in your CDN logs; a value below 40–50% for returning visitors suggests Alt-Svc cache TTL is too short or is being stripped by an intermediate proxy.
QPACK Blocked Streams
A request whose header block references a QPACK dynamic-table entry that has not yet arrived on the encoder stream cannot be decoded, and the receiver must hold it until the update lands. On a clean network this is invisible; on a path that reorders or drops packets it reintroduces a cross-stream dependency into a protocol chosen specifically to avoid one. If chrome://net-internals/#http3 shows requests waiting with no bytes outstanding of their own, lower SETTINGS_QPACK_BLOCKED_STREAMS — the extra header bytes are cheaper than the stall.
The Reset Limiter Closing a Healthy Connection
Servers hardened against the Rapid Reset technique count RST_STREAM frames per connection and terminate connections that exceed a threshold. Legitimate clients hit it when a page cancels a large number of in-flight requests in a short window — fast scrolling through a lazily fetched grid, an aborted search-as-you-type, or a router that cancels every pending fetch on navigation. The tell is a GOAWAY with no error on the application side followed by a fresh handshake mid-session. Reduce speculative fetches that get cancelled before you reach for the server-side limit.
0-RTT Replay Exposure
QUIC 0-RTT resumption sends application data before the handshake completes, using cryptographic material from a previous session. Because the server cannot distinguish a replayed 0-RTT packet from a legitimate one during the handshake, non-idempotent requests (POST, PUT, DELETE) sent over 0-RTT are vulnerable to replay attacks. Production deployments must either restrict 0-RTT to GET and HEAD, or maintain a server-side anti-replay cache keyed on the session ticket. See CDN edge tuning for QUIC and HTTP/3 for CDN-specific 0-RTT configuration.
FAQ
Does HTTP/3 always outperform HTTP/2?
Not on low-latency, low-loss networks. QUIC’s per-packet acknowledgment and user-space congestion control add CPU overhead that cancels out the latency advantage when the network is reliable. HTTP/3 provides the largest gains on high-latency or lossy connections (mobile networks, satellite), where TCP’s head-of-line blocking causes cascading stream stalls.
Should I disable HTTP/2 Server Push if my CDN still supports it?
Yes. Server Push was removed from Chrome in 2022. Pushed resources bypass the browser’s cache check, wasting bandwidth on assets the client already has. The correct replacement is 103 Early Hints: the server emits an informational response with Link: rel=preload headers, and the browser decides whether to fetch based on its cache state.
How do I verify ALPN negotiated correctly?
In Chrome DevTools → Network tab, enable the Protocol column. Requests showing h3 confirmed HTTP/3; h2 confirmed HTTP/2. If you see http/1.1 for requests you expected on HTTP/2, check that your server’s ALPN list includes h2 and that TLS 1.2 or TLS 1.3 is enabled. For HTTP/3, also check that Alt-Svc is being set in responses and is not being stripped by a load balancer or WAF.
What is the right MAX_CONCURRENT_STREAMS value?
Start at 100. Increase if monitoring shows stream queuing (visible as Stalled timing in DevTools). Decrease if server memory is constrained — each open stream holds receive buffers, header compression tables, and flow control state. On origin servers behind a CDN, a lower value (50–100) is usually fine because the CDN terminates the browser connections and manages multiplexing independently.
Can I use fetchpriority on CSS and fonts, or only images?
fetchpriority applies to <img>, <link rel="preload">, <script>, and fetch(). It does not apply to <link rel="stylesheet"> directly (stylesheet requests are always Highest when render-blocking). Use fetchpriority="high" on a <link rel="preload" as="style"> to boost a stylesheet that is being preloaded asynchronously, or fetchpriority="low" to deprioritize a non-critical preloaded font.
Can raising MAX_CONCURRENT_STREAMS make a page slower?
Yes, and this surprises people. Concurrency does not create bandwidth; it divides it. A server that round-robins fairly across 200 open streams gives the LCP image 0.5% of the pipe, and every resource finishes at roughly the same late time instead of finishing in priority order. High concurrency is only safe on a server that schedules by urgency. If your server round-robins, a lower limit (30–50) plus correct priorities often beats a high limit, because the browser’s own queue is priority-ordered and does a better job than a fair-share scheduler.
Why did my HTTP/3 rollout not move p75 LCP?
Almost always because a minority of navigations actually used it. First visits arrive over TCP until Alt-Svc is cached, UDP-blocked networks fall back, bots and synthetic monitors often speak HTTP/1.1, and a CDN that terminates h3 but talks HTTP/1.1 to origin throws away priority metadata at the second hop. Split your field data by nextHopProtocol before concluding anything, and check what fraction of navigations even reached the treatment.
Do I still need to bundle JavaScript under HTTP/2 and HTTP/3?
Less than before, but yes. Multiplexing removes the connection cost of an extra file, not its per-request cost: a header block to compress and decompress, a scheduling decision, a cache lookup, and a compression context that starts cold for each response — a hundred 2 KB modules compress far worse in aggregate than one 200 KB bundle. Route-scoped chunking into roughly 20–50 files gives you cache granularity without the death-by-a-thousand-requests effect; a fully unbundled dev-style module graph in production is still a measurable regression.
How many connections does a browser open to one origin over HTTP/3?
Ideally one. In practice a browser may hold several to the same origin because its socket pools are partitioned: by top-level site (so the same analytics origin embedded on two parent sites gets two connections), by credentials mode for anonymous CORS fetches, and by network state — a change of interface creates a new path, though QUIC connection migration lets an existing session survive the switch rather than re-handshaking. When you see two h3 connections to the same host in DevTools, look at the partition before assuming coalescing failed.
Should I switch QUIC congestion control from CUBIC to BBR?
Only with a measurement plan. BBR estimates bottleneck bandwidth and round-trip propagation time instead of treating every loss as congestion, which is why it holds throughput on lossy mobile paths where CUBIC collapses. The counterweight is fairness: BBR can claim more than its share against CUBIC flows at a shared bottleneck, and on a well-provisioned network the difference is nearly zero. Roll it out to a slice of traffic and compare p75 and p95 body-transfer time per arm before making it the default.
My CDN speaks HTTP/3 to browsers but HTTP/1.1 to my origin. Does the upgrade still help?
Yes for the last mile, which is where nearly all the latency and loss lives — the browser-to-edge hop gets multiplexing, 0-RTT resumption and per-stream loss recovery regardless of what happens behind the edge. What you lose is priority fidelity for uncached content: the edge cannot express urgency upstream over HTTP/1.1, so cache misses are fetched in arrival order and the edge’s own scheduler is the only thing restoring order on the way back out. Prioritise raising your edge cache hit ratio over upgrading the origin hop.