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).
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.
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 |
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.
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.
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.
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.
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. - 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.
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;
}
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
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.).
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.
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.
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.
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.
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.