Why QUIC
HTTP/2 solved most of HTTP/1.1's problems (multiplexing, binary framing, header compression). But HTTP/2 over TCP has a fundamental flaw: head-of-line blocking at the transport layer.
When a single packet is lost in a TCP stream, the entire stream waits for retransmit. HTTP/2 supports parallel streams at the application layer, but all of them share one TCP socket, so a lost packet blocks every stream at once.
On mobile networks this is particularly painful: high jitter, frequent packet loss. HTTP/2 on mobile is often slower than HTTP/1.1.
QUIC addresses this by moving the transport to user-space over UDP:
- Multiplexing at the transport layer itself
- TLS 1.3 built in (no separate handshake)
- Connection ID instead of 4-tuple (connection survives an IP change)
- 0-RTT resume
How QUIC differs from TCP
| Feature | TCP+TLS | QUIC |
|---|---|---|
| Transport | kernel | user-space (over UDP) |
| TLS | separate layer (RFC 5246) | built in (TLS 1.3 message in QUIC frame) |
| Handshake before encrypted data | 2-3 RTT | 1 RTT (or 0-RTT for known servers) |
| Stream multiplexing | no (single stream) | yes, independent streams |
| HOL-blocking | yes (at TCP level) | no (between streams) |
| Connection ID | 4-tuple (src/dst IP:port) | random ID, survives IP change |
| Runs in | OS (sendfile-friendly) | user-space (higher CPU load) |
QUIC vs TCP+TLS handshake
TCP+TLS 1.3:
client server
│ ── SYN ────────────►│
│ ◄── SYN/ACK ────────│ <- 1 RTT TCP
│ ── ACK + ClientHello ─►│
│ ◄── ServerHello + Cert │ <- 2 RTT for encrypted
│ ── Finished + HTTP req ►│
Total: ~2 RTT to data
QUIC:
client server
│ ── Initial(ClientHello) UDP ──►│
│ ◄── Initial(ServerHello+Cert) ─│ <- 1 RTT
│ ── Handshake done + HTTP req ─►│
Total: ~1 RTT
With 0-RTT (resuming a known server), data can be sent immediately with the first packet. Latency drops sharply for repeat visits.
0-RTT and its risks
On a repeated connection, the client uses a session ticket from the previous session and sends early data before the handshake is confirmed.
This is excellent for performance, but:
- Replay attack: an attacker can capture early data and resend it. If the request changes state (POST), a replay is dangerous.
- Server-side restriction required: only idempotent methods (GET, HEAD), or full replay protection via nonce.
In nginx:
ssl_early_data on;
# Block non-idempotent methods:
if ($request_method !~ ^(GET|HEAD)$) {return 405;
}
Stream multiplexing
QUIC has streams: independent bidirectional flows inside a single connection. Each stream has its own ID, its own retransmits, its own flow control.
QUIC Connection
├── Stream 0 (control)
├── Stream 4 (HTTP request 1), lost a packet, retransmitting
├── Stream 8 (HTTP request 2), continues normally
└── Stream 12 (HTTP request 3), continues normally
With TCP, a single lost packet would block all of these. With QUIC, only Stream 4 is affected.
Connection migration
The connection identifier is a random byte-string in the QUIC header, not tied to an IP address. When a client switches from Wi-Fi to 4G:
- source IP changes
- source UDP port changes
- Connection ID stays the same
The server sees a new IP with the same CID and silently updates the 4-tuple. The connection continues. TCP would have torn down and required a full reconnect.
Useful for:
- Mobile applications (subway, street)
- Long-running uploads
- WebRTC over QUIC
QPACK: header compression without HOL
HTTP/2 uses HPACK, compression with a shared dictionary. The problem: both endpoints must know the dictionary state, otherwise decoding breaks. Over HTTP/2-over-TCP this worked (TCP guarantees order). Over QUIC it does not (streams are independent).
QPACK reworks HPACK: the dictionary lives in a separate stream with coordinated updates. More complex, but it enables header compression without HOL.
HTTP/3: HTTP semantics over QUIC
HTTP/3 describes how HTTP/2-style requests and responses are packed into QUIC streams. The semantics are the same:
- Methods (GET/POST/...), URL, headers, body
- Status codes (200/404/...)
Differences from HTTP/2:
- QPACK instead of HPACK (header compression)
- Server Push is not supported (removed from the roadmap)
- Each HTTP request lives in its own QUIC stream
For your application the difference is minimal: the same curl https://...,
the same fetch() in the browser. The transport handles everything for you.
ALPN discovery: how the client learns the server supports h3
The browser first connects via HTTPS/TCP and during the handshake
receives an Alt-Svc header:
Alt-Svc: h3=":443"; ma=86400
This says "try h3 on UDP/443 next time." The browser caches it and uses QUIC on the next request.
The alternative is the HTTPS DNS RR (RFC 9460): the server declares h3 support in DNS:
example.com. 300 IN HTTPS 1 . alpn="h3,h2"
Stack support
| Software | Version with h3 | Note |
|---|---|---|
| nginx | 1.25+ | full support |
| Caddy | 2.6+ | default-on |
| Apache | mod_http3 (exp.) | in progress |
| HAProxy | 2.6+ | via quictls |
| curl | 7.66+ with --http3 | requires quiche/msh3 backend |
| wget2 | 2.0+ | yes |
| Cloudflare/Fastly/Cloud LB | yes, default | transparent |
| Chrome / Firefox / Safari | yes, by default | feature-detection |
Enabling HTTP/3 in nginx
server {listen 443 ssl; # TCP/TLS
listen 443 quic reuseport; # UDP/QUIC
ssl_protocols TLSv1.3; # QUIC requires TLS 1.3
ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem;
add_header Alt-Svc 'h3=":443"; ma=86400';
ssl_early_data on; # 0-RTT
# ... rest is identical to TCP config
}
Do not forget to open UDP/443 in the firewall.
ufw allow 443/udp
iptables -A INPUT -p udp --dport 443 -j ACCEPT
Performance
Real numbers (depend on RTT and loss rate):
- Low RTT, low loss (datacenter): QUIC is slightly slower than TCP (CPU overhead from user-space)
- High RTT, low loss (CDN, edge): QUIC wins due to a shorter handshake
- High RTT, high loss (mobile network): QUIC wins significantly, no HOL blocking
Cloudflare measured: QUIC is on average 5-15% faster on page load for mobile users, up to 30% on poor networks. For cable/fiber, the difference is within statistical noise.
CPU: QUIC uses 30-50% more CPU than TCP because it runs in user-space (no kernel sendfile, no TSO/GSO until recent offload support). By 2026 many NICs and kernel versions are gaining QUIC offload (UDP-segmentation offload + TLS-offload), closing the gap.
Middlebox interference
TCP is open to middlebox inspection (headers in plaintext, sequence numbers visible). QUIC is almost entirely encrypted: only the connection ID and part of the header are in the clear. A middlebox cannot:
- Modify packets (breaks them)
- Track state (sees only a UDP flow)
- DPI on content
From the ops-team perspective:
- Firewall must pass UDP/443 (often blocked by enterprise proxies out of habit)
- NAT tables must age UDP flows correctly (default 30 s is too short for QUIC; 5+ min is better)
- DPI/IDS loses visibility; the security posture shifts
Troubleshooting
- Browser not using h3: no
Alt-Svcheader or DNS HTTPS RR. Test with curl:curl --http3 -v https://example.com. quictlsfails to build: nginx requires BoringSSL/quictls (OpenSSL forks with the QUIC API). Standard OpenSSL before 3.5 does not support it.- UDP/443 not working: firewall (90% of cases), or the load balancer does not pass UDP. AWS ALB does not support h3; use NLB.
- 0-RTT replay attack: see the warnings above. Never allow non-idempotent requests through 0-RTT.
- High CPU on the QUIC server: enable kernel UDP-GSO and TLS-offload where available.
- Connection migration not working: some middleboxes drop packets with an unfamiliar CID. Usually works transparently.
- Old clients cannot connect: QUIC v1 is not draft-29. nginx 1.25+ supports only v1; older curl builds may expect draft-29.
When to choose QUIC
- Mobile-heavy traffic: a real win
- High-RTT clients (other continents): handshake advantage
- Many small requests: multiplexing without HOL
When to skip QUIC or wait:
- Internal RPC in a datacenter: TCP+TLS is proven; QUIC overhead is not justified
- Long-running streams (file upload, video): TCP handles them well
- If your stack, firewall, or observability is not ready, QUIC adds complexity