CDN Edge Tuning for QUIC & HTTP/3

UDP-based QUIC transport eliminates the transport-layer head-of-line blocking that plagues HTTP/2 over TCP, but only when the CDN edge is configured to advertise, negotiate, and schedule HTTP/3 streams correctly. This page covers every layer of that configuration: ALPN negotiation, TLS 1.3 handshake optimization, RFC 9218 stream priority mapping, connection migration, and the telemetry workflows that prove QUIC is actually being used in production.


QUIC vs TCP: Connection Lifecycle and Head-of-Line Blocking Sequence diagram comparing TCP+TLS+HTTP/2 connection setup (3 round trips before first byte) with QUIC+HTTP/3 (1 RTT or 0 RTT resumption), and showing how a lost packet stalls all TCP streams but only one QUIC stream. TCP + TLS 1.3 + HTTP/2 QUIC + HTTP/3 Client Edge SYN SYN-ACK ACK + TLS ClientHello TLS ServerHello + Cert TLS Finished HEADERS (stream 1,3,5…) Packet loss → ALL streams stall DATA (delayed) RTT 1 RTT 2 RTT 3+ Client Edge Initial (ClientHello inside QUIC) Handshake + HEADERS (1-RTT) Stream 0 (CSS, u=0) DATA stream 0 Stream 4 (JS, u=2) continues Loss: only stream 2 stalls 0-RTT resume: data before ACK RTT 1

What QUIC Changes at the Transport Layer

HTTP/2 multiplexes streams over a single TCP connection. TCP’s ordered delivery guarantee means that when one segment is lost, the TCP layer must retransmit it before delivering any data from subsequent segments — stalling every HTTP/2 stream simultaneously. This is transport-layer head-of-line blocking, and it is structural to TCP; no HTTP/2 tuning can eliminate it.

QUIC replaces TCP with a UDP-based transport. Each QUIC stream has independent flow control: a lost UDP packet triggers retransmission only for the stream that owns that packet number. All other streams continue delivering data. This is the fundamental difference — not an incremental improvement but a protocol boundary.

Engine-level behaviour differences determine how this plays out in practice:

Concern Chromium (Chrome/Edge) WebKit (Safari) Gecko (Firefox)
HTTP/3 enabled by default Yes (Chrome 87+) Yes (Safari 14+) Yes (Firefox 88+)
0-RTT early data Supported; gated by server quic_retry Supported Supported
Connection migration on IP change Supported (QUIC CID-based) Partial (Wi-Fi→LTE tested) Experimental flag
Priority header (RFC 9218) Full support (Chrome 96+) Partial Partial
Alt-Svc cache persistence Persistent across restarts Session-scoped Session-scoped
UDP/443 fallback heuristic QUIC timeout → TCP after ~300 ms Similar Similar

Chromium’s QUIC implementation is the most complete. When targeting a wide audience, prioritize validating behaviour in Chrome DevTools, then cross-check Safari’s session-scoped Alt-Svc behaviour.

Spec & API Reference: Key Directives and Headers

Alt-Svc: advertising HTTP/3 availability

Alt-Svc tells a browser that a superior protocol is available at a given host and port. The browser caches this and upgrades on the next connection. Without this header, HTTP/3 is never negotiated for new visitors.

Field Value Notes
h3=":443" QUIC on same port Standard configuration for co-located HTTP/2+HTTP/3
ma=86400 Max-age in seconds 24 h cache; set lower during rollout for faster fallback
h3-29=":443" Draft-29 alias Legacy; only needed for Chrome < 91
clear Invalidate cache Forces browsers to forget a previously advertised Alt-Svc

Priority header (RFC 9218)

RFC 9218 replaces HTTP/2’s binary dependency tree with a flat urgency field. Browsers emit Priority on HTTP/3 requests; edges can also respond with Priority to advise clients.

Urgency (u=) Semantic Maps from fetchpriority
0 Highest — render-blocking fetchpriority="high" on LCP image or critical CSS
1 High Preloaded fonts, above-fold async scripts
2 Medium-high Render-blocking scripts without explicit hint
3 Medium (browser default) Most resources without an explicit hint
4 Medium-low Below-fold images, deferred JS
5–6 Low Prefetched pages, speculative loads
7 Lowest Background analytics, non-critical beacons
i (incremental) Can interleave Safe for streaming or chunked responses

QUIC transport directives (Nginx)

Directive Purpose
quic_gso on Enable Generic Segmentation Offload for UDP batching — reduces CPU per packet
quic_retry on Issue a Retry packet to validate client address before 0-RTT data
quic_idle_timeout 30s Close idle QUIC connections; tune based on observed connection churn
ssl_early_data on Enable 0-RTT; combine with quic_retry for replay protection
http3_max_concurrent_pushes 0 Disable HTTP/3 push (deprecated in favour of 103 Early Hints)

Step-by-Step Implementation

Step 1 — Enable the QUIC listener and advertise HTTP/3

# nginx.conf (nginx-quic build or Nginx 1.25+)

server {
    # Bind UDP/443 for QUIC alongside the existing TCP listener
    listen 443 quic reuseport;   # UDP — required for QUIC
    listen 443 ssl;               # TCP — retained for HTTP/1.1 and HTTP/2

    ssl_protocols TLSv1.3;        # QUIC mandates TLS 1.3 — older versions are rejected
    ssl_early_data on;            # Allow 0-RTT on resumption (combine with quic_retry)

    quic_gso on;                  # Batch UDP segments via GSO; cuts CPU ~20% on high-traffic edges
    quic_retry on;                # Validate client address before accepting 0-RTT early data

    # Tell browsers HTTP/3 is available; 86400 s = 24 h Alt-Svc cache
    add_header Alt-Svc 'h3=":443"; ma=86400' always;
    add_header QUIC-Status $quic always;  # Log actual negotiated protocol for debugging
}

Why reuseport: QUIC’s connection ID demultiplexing requires that all UDP datagrams for a connection reach the same worker process. reuseport lets the OS distribute connections across workers while the QUIC stack routes by connection ID within each worker.

Step 2 — Map fetch-priority signals to RFC 9218 Priority headers

The browser emits Priority: u=N on HTTP/3 requests based on its internal heuristics and any fetchpriority attribute. Confirm the edge is not stripping or overriding these headers:

# Preserve client-supplied Priority header downstream; do not rewrite it
proxy_pass_header Priority;

# For static assets served directly, inject a conservative default
# so the browser doesn't have to guess urgency
location ~* \.(css|js)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    # No Priority override here — browser's inferred value is correct for most assets
}

location ~* \.(webp|avif|jpg|png)$ {
    # Below-fold images: signal low urgency so LCP image isn't starved
    add_header Priority "u=4" always;
}

For LCP images, the urgency must be signalled from the HTML side via fetchpriority="high" — the browser translates this to Priority: u=0 on the HTTP/3 request automatically. You can verify this in the Network panel (see Verification Workflow below).

Step 3 — Configure connection-level QUIC timeouts

# Align idle timeout with your application's session keep-alive
quic_idle_timeout 30s;   # Close idle QUIC connections; reduce if you see connection churn

# Initial congestion window: larger values help on high-BDP links (e.g. transatlantic CDN PoPs)
# Requires a QUIC build with BBR or CUBIC support
# quic_cc bbr;            # Enable BBR congestion control where supported

Step 4 — Validate the Alt-Svc cache and upgrade path

The first request from a new visitor always uses TCP (HTTP/2 or HTTP/1.1) because no Alt-Svc entry is cached yet. Only from the second connection onward does the browser attempt QUIC. Test this sequence explicitly:

# Confirm Alt-Svc header is present on the initial TCP response
curl -sI --http2 https://example.com | grep -i alt-svc
# Expected: Alt-Svc: h3=":443"; ma=86400

# Confirm QUIC upgrade on subsequent connection (requires curl with QUIC support)
curl -sI --http3 https://example.com | grep -i quic

Step 5 — Wire priority hints in the application layer

<!-- LCP image: fetchpriority="high" maps to Priority: u=0 on the HTTP/3 request -->
<img src="/hero.avif" fetchpriority="high" loading="eager" alt="Hero image">

<!-- Below-fold images: explicit low priority prevents bandwidth contention during LCP -->
<img src="/gallery-1.avif" fetchpriority="low" loading="lazy" alt="Gallery image">
// Programmatic fetch: align priority with the resource's role in the render pipeline
// 'high' → browser emits Priority: u=0 on the QUIC stream
fetch('/api/critical-config', {
    priority: 'high',   // Scheduling rationale: needed before first render
    cache: 'force-cache'
});

// Background analytics: low priority avoids starving render-critical streams
fetch('/analytics/beacon', {
    priority: 'low',    // Scheduling rationale: non-blocking, can wait
    keepalive: true
});

Verification Workflow

Chrome DevTools: confirm HTTP/3 delivery

  1. Open DevTools → Network panel.
  2. Right-click any column header → enable Protocol.
  3. Reload the page.
  4. Filter by h3 or quic in the Protocol column. Resources showing h3 are using QUIC transport.
  5. Click a resource → Timing tab. A QUIC connection shows Connection Start near zero on resumption (0-RTT) or a single combined SSL + Connection entry for 1-RTT.
  6. Check that LCP image entries show h3 with a Priority: u=0 request header. Open the resource → HeadersRequest Headers.

PerformanceObserver: RUM protocol tracking

// Emit a RUM event for every resource, tagging the negotiated protocol
// and TTFB so you can track QUIC delivery in production
const observer = new PerformanceObserver(list => {
    for (const entry of list.getEntries()) {
        if (entry.entryType !== 'resource') continue;

        const protocol = entry.nextHopProtocol; // 'h3', 'h2', or 'http/1.1'
        const ttfb = entry.responseStart - entry.startTime;

        // Report to your analytics pipeline
        analytics.track('resource_timing', {
            url: entry.name,
            protocol,
            ttfb: Math.round(ttfb),
            duration: Math.round(entry.duration),
            initiatorType: entry.initiatorType
        });
    }
});
observer.observe({ type: 'resource', buffered: true });

// Also check the navigation entry — confirms whether the HTML document itself used QUIC
const [nav] = performance.getEntriesByType('navigation');
console.log('Document protocol:', nav.nextHopProtocol);

Wireshark: packet-level QUIC inspection

For deep debugging of retransmission spikes or connection migration failures:

  1. Apply display filter: udp.port == 443 && quic
  2. Look for QUIC STREAM frames. High retransmission counts on a specific stream indicate MTU or path interference.
  3. CRYPTO loss or ACK storm indicates firewall interference with QUIC handshake packets.
  4. Connection migration appears as PATH_CHALLENGE / PATH_RESPONSE frame pairs with a different source IP.

Lighthouse: automated audit

Lighthouse does not have a dedicated HTTP/3 audit, but the Uses efficient cache policy and Server response times audits are sensitive to protocol efficiency. Run with --throttling-method=devtools on a profile that has the Alt-Svc cache warm to get realistic QUIC TTFB measurements.

Edge Cases & Gotchas

0-RTT replay exposure

Enabling ssl_early_data allows the client to send HTTP requests in the first QUIC flight, before the server confirms the connection is fresh. Any idempotent GET request can be safely replayed; POST, PUT, and DELETE must not. The quic_retry directive forces the edge to issue a Retry packet and validates a token before accepting early data — this adds one round trip for new connections but eliminates replay risk. Configure your origin to reject 0-RTT on mutating endpoints:

# Reject early data on routes that modify state
location /api/ {
    if ($ssl_early_data = "1") {
        # Return 425 Too Early to signal the client must retry with 1-RTT
        return 425;
    }
    proxy_pass http://backend;
}

UDP/443 firewall blocking and fallback

Corporate firewalls and some mobile carriers drop UDP/443 silently. Browsers detect this by measuring QUIC handshake timeout (typically 300 ms in Chromium) and downgrade to TCP. The Alt-Svc: clear response header lets you remotely invalidate cached Alt-Svc entries when you need to roll back HTTP/3 quickly across all browsers. Set ma to a lower value (e.g. 300) during initial rollout so fallback takes effect faster.

Cache key fragmentation

Over-segmenting edge cache keys by protocol (h2 vs h3) fragments stored content and reduces hit ratios. QUIC can serve the same byte ranges from entries already cached under HTTP/2. Use URL + Vary headers as the sole cache dimensions. If your CDN vendor creates protocol-specific cache namespaces, disable that feature.

HTTP/2 server push deprecation

HTTP/2 server push was intended to pre-deliver resources before the browser requested them. Chrome removed support in 2022. HTTP/3 inherits this removal. The correct replacement is 103 Early Hints, which lets the server emit preload Link headers before the full response is ready — without the cache poisoning risk that made push problematic. Ensure your edge CDN emits 103 rather than attempting PUSH_PROMISE frames.

Connection coalescing and QUIC

Connection coalescing allows a browser to reuse an existing HTTP/2 or HTTP/3 connection for a second origin if both origins resolve to the same IP and present the same TLS certificate (a wildcard or SAN cert). This means assets on static.example.com can be served over the same QUIC connection as www.example.com, saving an additional handshake. Verify this with the Connection ID column in DevTools Network panel — coalesced requests share the same ID.

MTU and QUIC packet sizing

QUIC packets over UDP are subject to path MTU. The default safe MTU is 1200 bytes (QUIC spec minimum). Paths with a smaller MTU (e.g. tunneled corporate networks) force QUIC to split data into smaller datagrams, increasing overhead. If you observe high retransmission rates in Wireshark, test with quic_mtu 1200 explicitly and check whether increasing it to 1400 bytes (common for Ethernet) improves throughput on your target geographies.

FAQ

Does enabling HTTP/3 on the CDN automatically make all users use QUIC?

No. The first connection from any new visitor always uses TCP because no Alt-Svc entry is cached yet. QUIC is attempted from the second connection onward. Users behind UDP-blocking firewalls will permanently fall back to HTTP/2. A realistic production QUIC adoption rate is 60–80 % of traffic for consumer audiences and lower for enterprise audiences.

How do I roll back HTTP/3 if I see problems in production?

Respond with Alt-Svc: clear from your origin. This instructs browsers to invalidate their cached Alt-Svc entries and stop attempting QUIC for your domain. Because the cache is session-scoped in Safari and persistent in Chrome, expect Chrome users to clear within the cached ma period. Set a low ma value (300 s) during rollout to minimize this window.

Should I enable HTTP/3 push (PUSH_PROMISE)?

No. HTTP/3 inherits HTTP/2’s push mechanism at the framing level, but Chrome and Firefox removed support. Use 103 Early Hints instead — your CDN edge can emit preload link headers for render-critical CSS and fonts as soon as it receives the request, before the origin responds.

What quic_idle_timeout value should I use?

Match it to your CDN’s TCP keep-alive timeout and your application’s session duration. A value of 30 s is conservative and safe. Setting it too low causes excessive connection churn on slow pages; too high keeps connections open past their useful life, wasting server memory. Monitor connection reuse ratios per PoP — if reuse is below 70 %, raise the timeout.

Does QUIC improve LCP on high-latency connections?

Yes, measurably. On connections with round-trip times above 100 ms, the 0-RTT resumption eliminates one full RTT from TTFB. Combined with the absence of transport-layer head-of-line blocking, LCP typically improves 5–15 % on mobile networks in high-latency regions, though the exact gain depends on the number of render-blocking resources and their sizes.