Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

AlgorithmKey size (128-bit security)Signature sizeHandshake CPU
RSA3072 bits384 bytes~3ms
ECDSA P-256256 bits64 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 443
  • ma=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:

Protocol0% loss1% loss5% 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"