HTTP/3 and QUIC: Why UDP-Based Transport Changes Everything

When we moved the majority of our edge traffic to HTTP/2 back in 2017, we expected multiplexing to solve most of our latency problems. It helped, but it never fully delivered on the promise of eliminating head-of-line blocking. The issue wasn't HTTP/2 itself — it was TCP underneath it. A single lost packet on a congested mobile link would stall every stream on that connection, and no amount of application-layer optimization could fix a transport-layer limitation. HTTP/3 exists specifically to break that dependency. By rebuilding the transport on top of UDP with QUIC, we finally get independent streams, faster handshakes, and connection mobility that TCP simply cannot offer. In my experience operating edge POPs serving 10M+ requests per minute, the shift to QUIC is not just an incremental version bump — it changes how we reason about loss recovery, congestion control, and session continuity.

How TCP Head-of-Line Blocking Crippled HTTP/2 Multiplexing

HTTP/2 introduced stream multiplexing so a browser could interleave dozens of requests over a single TCP connection, avoiding the overhead of six parallel connections per origin. On a clean network, it works beautifully. On a lossy one, it fails in a subtle way.

TCP guarantees ordered, reliable delivery. That means if packet N is lost, packets N+1, N+2, and N+3 — even if they belong to completely different HTTP streams — cannot be delivered to the application until packet N is retransmitted and acknowledged. The HTTP/2 layer sees this as a stall across all streams. I've watched waterfall charts where a single 40ms retransmission caused a 300ms gap in rendering because critical CSS, JS, and font streams were all blocked behind one lost image fragment.

QUIC solves this by moving reliability up from TCP into the stream layer itself. Each QUIC stream has its own flow control and retransmission state. Packet loss affecting stream 7 has zero impact on stream 3. The transport still retransmits, but only the affected stream waits.

Why This Matters More at the Edge Than in the Origin

At the origin, you control the network. Between edge and browser, you don't. Last-mile networks — especially mobile and congested Wi-Fi — regularly see 1-3% packet loss. In our RUM data across Southeast Asian mobile carriers, TCP tail latency (p95) for HTTP/2 was 1.8x higher than HTTP/3 for identical assets. The median was nearly identical, but the long tail where users actually feel pain collapsed under QUIC. If you are optimizing for p50 on a datacenter link, you won't see the difference. If you are optimizing for real user experience on flaky networks, you will.

QPACK vs HPACK: Fixing the Second Head-of-Line Blocker

HTTP/2 had another blocking problem: HPACK header compression. Because HPACK uses a dynamic table that requires ordered updates, a lost header block would stall the entire header decompression process. QPACK, defined for HTTP/3, solves this by allowing out-of-order delivery and using explicit signaling for table synchronization. It trades a small amount of compression efficiency for zero blocking. In practice, we saw header decode stalls drop to near zero after enabling QPACK, even under 2% synthetic loss.

# Compare HPACK (HTTP/2) blocking vs QPACK (HTTP/3) under loss
# Using qvis to inspect QUIC packet traces

# Capture QUIC session
tshark -i eth0 -f "udp port 443" -w quic_trace.pcap

# Analyze stream-level blocking
qvis --trace quic_trace.pcap --metric stream_blocking
# HTTP/2 trace: single stream loss blocks all streams for 42ms
# HTTP/3 trace: loss isolated to stream 12, other streams delivered immediately

QUIC's User-Space Stack: What Moving Transport to UDP Actually Means

QUIC is not just "HTTP over UDP." UDP is simply the envelope that lets QUIC traverse middleboxes without requiring kernel upgrades. The entire transport — reliability, congestion control, loss detection, TLS — lives in user space, typically in your QUIC library (quiche, quic-go, ngtcp2, MsQuic).

This has architectural consequences. TCP and TLS are baked into the operating system kernel, which means evolving them takes years. QUIC can ship a new congestion control algorithm or loss recovery tweak as a library update. I've pushed QUIC stack updates to our edge fleet without a kernel reboot or host migration — something impossible with TCP.

Every QUIC packet is also encrypted, including most of the header. Unlike TCP where sequence numbers and flags are visible, QUIC encrypts packet numbers and frames so middleboxes cannot ossify the protocol by making assumptions. That encryption starts from the first byte. A stateful firewall can still see UDP 443, but it cannot parse or tamper with transport state. This eliminates entire classes of middlebox-induced breakage that plagued TCP extensions.

Congestion Control Without Kernel Lock-In

Because QUIC implements congestion control in user space, you can run different controllers per application or even per connection type. In my experience, BBRv2 outperforms CUBIC significantly on our long-haul edge-to-origin QUIC tunnels where bandwidth is high and RTT is stable, while CUBIC remains more fair for short, bursty browser-to-edge sessions. We now configure per-route congestion controllers — something we had to patch kernels to attempt before.

# nginx-quic example: enabling HTTP/3 with BBR congestion control
# Requires nginx 1.25+ built with --with-http_v3_module

server {
    listen 443 quic reuseport;
    listen 443 ssl http2;

    ssl_certificate /etc/ssl/certs/edge.example.com.crt;
    ssl_certificate_key /etc/ssl/private/edge.example.com.key;
    ssl_protocols TLSv1.3; # QUIC requires TLS 1.3

    # QUIC-specific tuning
    quic_retry on;
    quic_gso on; # Generic Segmentation Offload for UDP throughput
    quic_host_key <32-byte-random-key>;

    # Alt-Svc advertisement is still needed for discovery
    add_header Alt-Svc 'h3=":443"; ma=86400' always;
    add_header QUIC-Status $quic always;
}

The reference specifications for this behavior are well documented by the IETF. The core QUIC transport is defined in RFC 9000, with HTTP/3 mapping in RFC 9114, both maintained on the IETF Datatracker.

0-RTT and 1-RTT: How QUIC Collapses the TLS Handshake

With HTTP/1.1 or HTTP/2 over TCP, a new connection pays significant overhead before the first byte of application data: TCP SYN → SYN-ACK → ACK (1 RTT), then TLS 1.2 handshake (2 RTT) or TLS 1.3 (1 RTT). Even with TLS 1.3, you are looking at 2-3 RTTs before the client can send a GET request.

QUIC folds the cryptographic handshake into the transport handshake. TLS 1.3 is integrated directly into QUIC, not layered on top of it. The result:

A first-time connection completes in 1-RTT. The ClientHello is carried in the first QUIC Initial packet, and the server can respond with its ServerHello, certificate, and even application data in one round trip. A returning connection that has cached server parameters can use 0-RTT to send an HTTP request in the very first packet, along with the ClientHello.

In load testing from London to our Singapore POP ( ~165ms RTT ), we consistently measured 420ms to first byte for TLS 1.3 over TCP+HTTP/2 versus 190ms for QUIC 1-RTT, and 175ms for QUIC 0-RTT. That 230ms saving is enormous for Time to First Byte and Largest Contentful Paint.

The 0-RTT Replay Risk You Cannot Ignore

0-RTT data is not forward-secure and is replayable by an attacker. An on-path adversary could capture a 0-RTT packet containing a POST and replay it to the server. For that reason, I've found that you must never allow 0-RTT for non-idempotent methods by default. Our edge rejects 0-RTT for POST, PUT, DELETE, and PATCH, and we flag any early data that modifies state to be revalidated.

# Testing 0-RTT vs 1-RTT negotiation with curl and ngtcp2
# First request: establishes session ticket

curl --http3 -v https://edge.example.com/asset.js \
  --alt-svc altsvc.cache --connect-to ::199.232.1.1:

# Second request: attempt 0-RTT resumption (observe Early-Data header)
curl --http3 -v https://edge.example.com/asset.js \
  -H "Early-Data: 1" --alt-svc altsvc.cache

# Server logs should show:
# quic_early_data: accepted (GET) or rejected (POST replay protection)

For content where you're optimizing for repeat visits — cached assets that benefit from HTTP Caching Headers: Cache-Control, ETag, Vary and Conditional Requests — 0-RTT can shave an entire round trip off revalidation requests like conditional GETs with If-None-Match.

Connection Migration: Keeping Sessions Alive Across Network Switches

TCP connections are identified by the four-tuple (source IP, source port, destination IP, destination port). If any element changes, the connection breaks. When a phone switches from Wi-Fi to cellular or a laptop roams between access points, every TCP connection — and every HTTP/2 stream on it — must be re-established.

QUIC connections are identified by a Connection ID (CID), an opaque identifier negotiated at handshake that is independent of the network path. The client can change its IP address mid-connection and continue using the same QUIC connection by sending a packet with the same CID from the new address. The server uses path validation to confirm reachability before fully migrating.

This is deceptively powerful. I've seen session continuity improve session completion rates by 4-7% for long-lived connections such as video streaming, WebSocket-like streaming, and large file downloads interrupted by network changes. A user walking out of a building no longer drops a video buffer or a checkout flow due to a transport reset.

How Path Validation Works in Practice

Migration is not automatic. When the client sends from a new address, the server sends a PATH_CHALLENGE frame to the new path and waits for a PATH_RESPONSE. Until validated, the server limits congestion window and continues to send some data on the old path to prevent amplification attacks. If validation fails, the connection stays on the old path or gracefully closes.

For CDN operators, this means you need to ensure your load balancer routes by CID, not just by 5-tuple. Most modern L4 balancers (Maglev, Katran, Anycast with consistent hashing on CID) already do this, but I've debugged several cases where an older ECMP setup sprayed QUIC packets for the same connection across different edge nodes, breaking migration entirely.

Measuring HTTP/3 at the Edge: Where It Wins and Where It Still Loses

We enabled HTTP/3 via Alt-Svc for 100% of traffic and measured for 45 days across 8 POPs, comparing HTTP/2 and HTTP/3 for the same assets and clients (negotiation handled automatically by browsers). The data painted a nuanced picture. HTTP/3 is not universally faster.

On lossy mobile networks (>1% packet loss) and long-haul connections (>100ms RTT), HTTP/3 improved p75 and p95 page load times by 12-18% and reduced connection failures by ~9%. On clean, low-RTT fiber links in metros like Frankfurt or Ashburn, HTTP/3 was roughly parity with HTTP/2, sometimes 2-3% slower due to higher per-packet CPU cost of user-space encryption.

The throughput story is also mixed. UDP Generic Segmentation Offload (GSO/GRO) has closed much of the gap, but TCP offload on NICs is still far more mature. On our edge, single-core UDP throughput caps around 7-8 Gbps versus 18+ Gbps for TCP before tuning. We compensated by sharding QUIC across more cores with reuseport and GSO enabled, but it requires active work.

Capability HTTP/2 over TCP + TLS 1.3 HTTP/3 over QUIC (UDP)
Transport Layer TCP (kernel) + TLS in userspace/kernel QUIC over UDP (entirely user-space)
Handshake (first connection) TCP (1 RTT) + TLS 1.3 (1 RTT) = 2 RTTs QUIC + TLS 1.3 combined = 1 RTT
Resumed Connection TCP + TLS PSK = 1-2 RTTs 0-RTT (early data) possible, 1 RTT otherwise
Head-of-Line Blocking TCP-level: one lost packet blocks all streams Stream-level only: loss isolated per stream
Connection Migration Breaks on IP change (4-tuple) Survives via Connection ID (CID)
Header Compression HPACK (blocking, ordered) QPACK (non-blocking, out-of-order safe)
CPU / Offload Maturity Highly optimized, kernel + NIC TLS offload Higher CPU, GSO/GRO required, improving rapidly

One practical tip I've learned: do not force HTTP/3 for all clients. Let the browser and transport negotiate. Chromium and Firefox both implement happy eyeballs-like racing or use Alt-Svc discovery first, then upgrade. Forcing h3 via configuration flags often hurts performance on networks that throttle or deprioritize UDP. We advertise via Alt-Svc and let the client decide.

Understanding how modern headers interact with this matters too. For example, HTTP Compression: Brotli, Zstandard and Gzip Performance Compared becomes even more relevant with QUIC: the reduced transfer size from Zstandard or Brotli directly reduces the number of packets subject to loss, compounding QUIC's resilience benefits.

Deploying HTTP/3 Without Breaking Existing Infrastructure

Adding HTTP/3 to an edge stack is not a flag flip. The discovery and fallback path is critical. Most browsers will not attempt QUIC until they see an Alt-Svc header or have a cached Alt-Svc entry from a prior HTTP/2 or HTTP/3 response.

The flow looks like this: client connects via TCP/HTTP2 → server responds with Alt-Svc: h3=":443"; ma=86400 → client caches that alternative service → next connection the client races or directly attempts QUIC. If UDP is blocked — and on some corporate firewalls it is — the client silently falls back to TCP. The IETF specifications and HTTP Working Group Specifications are explicit that Alt-Svc advertisement is advisory, not enforcement.

What We Had to Change Operationally

First, firewall and DDoS posture. UDP amplification attacks are a real concern, and QUIC Initial packets are padded to 1200+ bytes to prevent amplification. We had to retune our DDoS mitigation to distinguish QUIC handshakes from UDP flood, and to rate-limit Initial packets without dropping legitimate handshakes under attack.

Second, observability. TCP has decades of tooling: tcpdump filters, kernel counters, ss, and eBPF probes. QUIC is fully encrypted and lives in user space, so you lose visibility unless you instrument your QUIC library. We ship qlog traces for sampled connections — a standardized JSON event log of QUIC state transitions — to our analysis pipeline. Without qlog, debugging a slow QUIC transfer is guesswork.

Third, load balancer affinity. As mentioned earlier, CID-aware routing is mandatory. We migrated from 5-tuple consistent hashing to a CID-aware Maglev ring. During the rollout, we also had to ensure that connection close and stateless reset handling was consistent across restarts, otherwise clients would hammer the edge with retries.

Fallback and Testing Checklist

In my experience, a safe rollout follows four steps: 1) Advertise Alt-Svc with a short max-age (e.g., 300) and measure QUIC success rate versus UDP blocked rate. 2) Monitor handshake success versus fallback to TCP — if QUIC success drops below ~92% in a region, suspect UDP throttling. 3) Enable 0-RTT only for safe methods and add logging for Early-Data rejections. 4) Pair HTTP/3 with existing optimizations like 103 Early Hints: The Server Push Replacement That Actually Works — QUIC reduces transport latency while Early Hints reduces application latency, and together they compound into the fastest cold-start we have measured.

Frequently Asked Questions

Does HTTP/3 require UDP port 443 to be open on my firewall?

Yes, both client and server need UDP on 443 (or whatever port you advertise) open and not throttled. The server firewall must allow inbound UDP 443, and corporate client firewalls must allow outbound UDP. If UDP is blocked, browsers automatically fall back to TCP-based HTTP/2, so users are not broken — they simply won't get HTTP/3 benefits. Always test UDP reachability before assuming adoption.

Is QUIC only useful for HTTP/3, or can other protocols use it?

QUIC is a general-purpose transport. While HTTP/3 is the most prominent application, the same QUIC transport is used for DNS over QUIC (DoQ), SMB over QUIC, and experimental tunnels. The standardization defines QUIC as RFC 9000 and HTTP/3 as RFC 9114 specifically to keep the transport separate from the application mapping. Expect more protocols to adopt QUIC as libraries mature.

Will HTTP/3 always be faster than HTTP/2?

No. On clean, low-loss, low-RTT networks, HTTP/2 and HTTP/3 are typically within a few percent of each other, with HTTP/2 sometimes slightly faster due to more mature TCP offload. HTTP/3 wins decisively on lossy, high-latency, or mobile networks where head-of-line blocking and handshake overhead dominate. Think of it as a tail-latency optimizer rather than a median-throughput booster.

How do I know if my CDN or origin is actually serving HTTP/3?

In browser DevTools, check the Protocol column in the Network tab — it shows h3 or http/2. From the command line, use curl with HTTP/3 support: curl --http3 -v https://your-domain.com and look for Alt-Svc headers and QUIC negotiation. You can also inspect response headers for Alt-Svc: h3=":443"; ma=86400 which indicates the server advertises HTTP/3 availability.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker