Lab 18 · HTTP/3 and QUIC
Run it:
make lab-18
Source:labs/lab-18-http3-quic/main.go
The Problem
TCP was designed in 1974. Every HTTP version from 0.9 to 2.0 runs on TCP. But TCP has a fundamental flaw for modern web performance: Head-of-Line (HoL) Blocking.
In HTTP/2, all streams share a single TCP connection. If one TCP packet is lost, all streams stall until the lost packet is retransmitted:
HTTP/2 connection (single TCP)
Stream 1: HTML [====| |=====>] ← stalled by lost packet
Stream 2: CSS [====| |=====>] ← stalled by lost packet
Stream 3: image [====| LOST |=====>] ← packet lost here
All streams wait for the retransmission of stream 3's packet.
On a path with 2% packet loss (common on mobile, satellite, congested networks), HTTP/2 throughput can be worse than HTTP/1.1 because of amplified HoL blocking.
QUIC solves this by rebuilding transport from scratch on top of UDP.
QUIC: A New Transport
QUIC (Quick UDP Internet Connections, RFC 9000) is a transport protocol built on UDP. It replicates TCP’s reliability guarantees while eliminating HoL blocking:
QUIC connection (over UDP)
Stream 1: HTML [=============>] ← independent stream
Stream 2: CSS [=============>] ← independent stream
Stream 3: image [===== LOST →] ← only this stream pauses for retransmit
Streams 1 and 2 continue unaffected.
Key QUIC Features
Connection IDs (CIDs)
In TCP, a connection is identified by (src IP, src port, dst IP, dst port).
Changing any element tears down the connection.
QUIC connections are identified by a 64-bit opaque Connection ID:
QUIC Connection: CID=0xdeadbeef01234567
Can migrate: src IP changes (mobile handoff) → connection survives
Can migrate: src port changes (NAT rebinding) → connection survives
This enables Connection Migration: a mobile user moving from WiFi to LTE doesn’t break QUIC connections. TCP connections would require a full TLS+TCP handshake on the new network.
0-RTT Resumption
A client that previously connected to a server can resume with 0 RTT:
1st connection: 1-RTT (QUIC INIT + crypto handshake)
2nd connection: 0-RTT (client sends data immediately with cached session ticket)
0-RTT data is not forward secret (replay attack risk). For safe 0-RTT:
- Non-mutating requests only (GET, HEAD, OPTIONS)
- Servers must use replay protection (nonce tracking or time-window limits)
QPACK Header Compression
HTTP/2 uses HPACK for header compression. HPACK requires in-order delivery (a single dynamic table synchronized between endpoints). HPACK breaks under packet reordering.
QPACK (RFC 9204) is QUIC’s header compression scheme. It uses separate encoder and decoder streams that don’t block request streams on packet loss.
TLS 1.3 Integration
QUIC encrypts the transport layer itself. There is no unencrypted QUIC. QUIC integrates TLS 1.3 handshake into its own handshake:
TCP/TLS 1.3: QUIC/TLS 1.3:
[TCP SYN] [Initial Packet (Client Hello)]
[TCP SYN-ACK] [Initial Packet (Server Hello)]
[TCP ACK] [Handshake Packet (Finished)]
[TLS ClientHello]
[TLS ServerHello] ← QUIC fuses these into fewer round-trips
[TLS Finished]
[First request] [First request]
Result: QUIC 1-RTT vs. TCP+TLS 2-RTT for new connections.
ECDSA vs. RSA Certificates
The lab generates a self-signed ECDSA P-256 certificate (not RSA). ECDSA offers smaller key sizes for equivalent security:
| Algorithm | Key size (128-bit security) | Signature size | Handshake CPU |
|---|---|---|---|
| RSA | 3072 bits | 384 bytes | ~3ms |
| ECDSA P-256 | 256 bits | 64 bytes | ~0.3ms |
For a CDN terminating millions of TLS connections per second, ECDSA is significantly more efficient. Cloudflare uses ECDSA certificates by default.
// Generate ECDSA P-256 key
privKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
// Self-signed certificate
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
DNSNames: []string{"localhost"},
}
certDER, _ := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey)
quic-go Implementation
The lab uses github.com/quic-go/quic-go:
import (
"github.com/quic-go/quic-go/http3"
"net/http"
)
// HTTP/3 server runs on UDP
server := &http3.Server{
Addr: ":443",
Handler: mux,
TLSConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
NextProtos: []string{"h3"}, // ALPN for HTTP/3
},
}
// Also run HTTP/1.1 + HTTP/2 on TCP (for clients that don't support H3)
go server.ListenAndServeTLS(certFile, keyFile) // UDP 443
// Alt-Svc header tells clients "this server speaks H3 on port 443"
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Alt-Svc", `h3=":443"; ma=86400`)
// ... serve content
})
Alt-Svc: Protocol Upgrade Negotiation
Browsers discover H3 support via the Alt-Svc response header:
Alt-Svc: h3=":443"; ma=86400
h3=":443"— server speaks HTTP/3 on port 443ma=86400— this advertisement is valid for 86400 seconds (24h)
On first request, the browser uses TCP (H1/H2). On subsequent requests, it connects via QUIC instead. The browser caches Alt-Svc per origin.
H1 vs. H2 vs. H3 Performance
Relative performance depends on network conditions:
| Protocol | 0% loss | 1% loss | 5% loss |
|---|---|---|---|
| HTTP/1.1 (6 connections) | baseline | -30% | -60% |
| HTTP/2 (1 connection) | +15% | -50% | -70% |
| HTTP/3 (QUIC) | +10% | -5% | -20% |
H3 shines on lossy/high-latency networks. On a clean datacenter network, H2 and H3 are comparable. This is why CDN edge nodes gain more from H3 than CDN-to-origin connections (origin is typically on a reliable path).
Firewall Considerations
QUIC runs on UDP. Many corporate firewalls block all non-DNS UDP traffic. When QUIC is blocked, clients fall back to TCP:
Client: send QUIC Initial packet (UDP 443)
Firewall: drops UDP 443
Client: timeout after ~150ms
Client: fall back to TCP + TLS 1.3
This is called “QUIC Happy Eyeballs”: parallel TCP and QUIC attempts, use whichever succeeds first. Chrome/Firefox implement this.
Browser QUIC adoption statistics (2024): ~28% of all web requests (dominated by Google services which pioneered QUIC via gQUIC).
Try It
make lab-18
# HTTP/1.1 request
curl -k http://localhost:8080/ -v
# HTTP/3 request (skip certificate validation for self-signed cert)
curl -k --http3 https://localhost:8443/ -v
# Benchmark: compare H1 vs H3 latency
time curl -k http://localhost:8080/large-file -o /dev/null
time curl -k --http3 https://localhost:8443/large-file -o /dev/null
# Verify Alt-Svc header is present
curl -k https://localhost:8443/ -I | grep Alt-Svc
# Should show: Alt-Svc: h3=":8443"; ma=86400
# Check what protocol was negotiated (curl verbose shows it)
curl -k --http3 https://localhost:8443/ -v 2>&1 | grep "Using HTTP"