9.5 HTTP/3 and QUIC
TCP was designed before HTTP existed. QUIC was designed *for* it — and this lesson closes the module with a real HTTP/3 handshake over UDP, verified byte by byte.
Intermediate
What you will learn
Why QUIC exists: the structural problems TCP+TLS cannot solve.
The configuration: the
quiclistener,http3 on,Alt-Svc, and the mandatory companions (reuseport, TLS 1.3).The proof: a real
--http3-onlyhandshake, and the h2 fallback path.QUIC-specific tuning:
quic_retry, 0-RTT early data, and connection migration.Operations: what changes for debugging, load balancing, and your Module 10 preview.
1. Why QUIC exists
Three structural complaints about TCP+TLS, none of them fixable in place:
Problem | TCP+TLS reality | QUIC's answer |
|---|---|---|
Handshake serialization | TCP (1 RTT) then TLS (1–2 RTT) — 2–3 RTT before the first byte | merged: crypto is the transport — 1 RTT, or 0-RTT for returning connections |
Head-of-line blocking | one lost packet stalls every stream behind it — your API call waits on an unrelated image | independent streams per request; loss affects only its own stream |
ossified middleboxes | 40 years of boxes that know TCP's shape — innovation (options, CC algorithms) gets blackholed | it's UDP with the logic in user space — evolvable without asking the internet's permission |
Plus connection migration: a QUIC connection is identified by a connection ID, not the 4-tuple — your phone switching from Wi-Fi to 5G keeps the same connection (the TCP version of that story is a 15-second stall and a session re-login).
For NGINX, QUIC arrived as the
http_v3 module (compile flag
--with-http_v3_module — present in your
nginx -V receipt since Lesson 1.3), and it
requires TLS 1.3: the crypto and the transport are the same machinery.
2. The configuration
The h3 server block, verbatim — every line load-bearing:
server {
listen 8943 quic reuseport; # (1) UDP listener, per-worker sockets
listen 8943 ssl; # (2) TCP listener: h2/h1.1 fallback
http2 on; # (3) h2 on the TCP side
http3 on; # (4) h3 on the QUIC side
server_name app.example.test;
ssl_certificate ../certs/app.example.test.pem; # (5) TLS 1.3 required
ssl_certificate_key ../certs/app.example.test.key;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Alt-Svc 'h3=":8943"; ma=86400' always; # (6) the on-ramp
quic_retry on; # (7) anti-amplification tokens
ssl_early_data off; # (8) 0-RTT: off for clarity
}listen … quicopens the UDP socket.reuseportis effectively required: it gives each worker its own UDP socket so the kernel hashes packets to owners — without it, one worker receives everything and QUIC's per-connection state becomes a bottleneck.The TCP listener on the same port is the compatibility story: clients that can't (or won't) speak h3 get h2 or 1.1 on TCP. One port, two protocols, one server block. 3–4.
http2/http3enable their respective listeners' protocol versions — the modern (1.25.1+) split of whatlisten … http2used to do.TLS 1.3 is non-negotiable — QUIC's handshake is TLS 1.3 messages carried in UDP frames.
Alt-Svcis the on-ramp. HTTP/3 has a chicken-and-egg problem(the client learns "h3 exists here" over… HTTP), solved by this header: "same host, port 8943 speaks h3; remember for 86 400 s." First visit rides TCP/h2 and learns; second visit goes straight to QUIC. (ma= max-age; there's alsoh3-29-style draft naming history you may meet in old configs.)quic_retry— QUIC's anti-amplification answer: the server issues a retry token the client must echo, proving it can receive at the address it claims (no spoofed-source reflection attacks) and validating the client address before the connection commits state.ssl_early_data— 0-RTT resumption: the returning client's first flight carries request data. Latency magic; replay-risk reality (the same early data can be captured and replayed — the TLS session ticket doesn't prove ordering). Off in this config for clarity; on in production only for idempotent, replay-tolerant request shapes, with$ssl_early_dataavailable to apps that want to check.
3. The proof — both paths
Both directions, real handshakes:
# over QUIC (UDP 8943):
$ curl -s -o /dev/null -D - --http3-only --cacert certs/ca.crt \
--resolve app.example.test:8943:127.0.0.1 https://app.example.test:8943/
HTTP/3 200 ← the wire protocol, verified
server: nginx/1.30.5
alt-svc: h3=":8943"; ma=86400
# over TCP, same port (fallback):
$ curl … --http2 …
HTTP/2 200Note curl's
--http3-only (strict) vs
--http3 (prefer, fall back) — the strict form is your test/assert tool; the prefer form is the realistic client. And note what the Alt-Svc receipt means: a browser that first visits over TCP will
self-upgrade to QUIC for the next one — you can watch the access log's protocol column flip (Module 12's
$server_protocol shows
HTTP/3.0).
4. QUIC-specific tuning
Directive | Default | The story |
|---|---|---|
| off | on in this config — address validation, anti-amplification; slight first-connection cost (one extra round trip) |
| 64k | per-stream buffering; raise for heavy single-stream consumers |
| protocol | how many connection IDs a client may stash (migration headroom) |
| off | 0-RTT; replay-caveated (§2.8) |
| (shared) | QUIC reuses the same idle knob — after
|
Two behaviors worth naming for operations: connection migration means client IP changes are not disconnects (mobile handoffs become non-events — visible as long-lived QUIC connections across address changes in logs); and streams, not connections, carry concurrency — one QUIC connection multiplexes like h2 but without TCP's shared loss queue (the anti-head-of-line property from §1).
5. Operations: what changes
Debugging moves up a layer. tcpdump still works (
udp port 8943), but the contents are encrypted transport frames — Module 16's toolbox leans onqlog-style tooling and NGINX's own logs where it used to lean on Wireshark's HTTP dissector.Load balancing changes shape. QUIC connections are identified by connection IDs — an LB that doesn't route by them breaks migration (worst case) or pins badly (best case). Cloud LBs are still catching up; Module 10's stream layer has opinions (UDP proxying is native), and
reuseport-per-worker is the single-host answer.Firewalls: UDP 443 (or your port) must be open — enterprises that "only allow TCP" quietly disable h3 for their users; your Alt-Svc just never fires there, and the TCP fallback earns its keep.
Measurement:
$server_protocolin logs (HTTP/3.0 vs HTTP/2.0)is the adoption dashboard; QUIC's handshake RTT shows up in your RUM tooling, not in$ssl_session_reused-style fields you may have built for TLS.
Try it yourself
Both handshakes, yourself (§3's exact commands) — then
curl -Vto confirm your tooling speaks h3, and try--http3(the realistic mode): which protocol answered?Watch Alt-Svc teach a client. Two sequential requests with a cookie jar won't upgrade (curl doesn't implement Alt-Svc caching —browsers do), but the header is there for the clients that do; check it on both the h2 and h3 responses.
UDP on the wire. Without tcpdump privileges you can still see the socket:
ss -ulnp | grep 8943— the UDP listener, live (Module 16 gets you the packets).Break the fallback. Comment out the TCP
listen, reload,curl --http2→ connection refused while--http3-onlystill works. You've just seen why the dual listener exists. Restore.
Q&A
Q1. Why does the QUIC listener need
reuseport when the TCP listener beside it works fine without?
Answer
TCP connections are discrete sockets handed to workers by the kernel's accept(); a single shared UDP socket would deliver
all QUIC packets to one receiver.
reuseport gives each worker its own UDP socket and lets the kernel hash connections (by connection ID) to owners — per-connection QUIC state stays worker-local.
Q2. A client's first visit is h2-over-TCP; the second is h3. What made that possible, and where is it configured?
Answer
Alt-Svc: h3=":8943"; ma=86400 — the header (the
add_header) teaches HTTP-speaking clients that the origin also serves h3 on that port, with a one-day memory. First contact is necessarily TCP-carried; the client self-upgrades thereafter.
Q3. Why is
ssl_early_data off by default, and what request shapes tolerate turning it on?
Answer
0-RTT data can be replayed (the ticket proves identity, not freshness) — an attacker re-sends the client’s early flight. Safe for idempotent, replay-tolerant requests (GETs of static content); unsafe for anything mutating. Apps can check
$ssl_early_data — the same variable covers TLS 1.3 and QUIC early data — to gate handling.
Q4. Name the two head-of-line-blocking layers HTTP/2 still has, and QUIC's answer to each.
Answer
TCP-level (one lost packet stalls all streams — QUIC: streams are independent transport-level objects, loss is per-stream) and priority-based scheduling delays (HOL at the multiplexer — improved in h3's simplified prioritization model).
Takeaways
QUIC rebuilds transport for HTTP: merged crypto handshake (1/0 RTT), per-stream loss isolation, UDP-carried evolvability, and connection migration.
The serving shape:
listen … quic reuseport+ TCP fallback on the same port +http3 on+ TLS 1.3 +Alt-Svcas the client on-ramp — all verified against a live server.quic_retryis the anti-amplification valve;ssl_early_datais the replay-caveated latency win;reuseportis how workers own UDP.Operations shift: connection IDs (not IPs) define connections, debugging goes encrypted-transport-shaped, UDP must be open, and
$server_protocolbecomes your adoption metric.
Module 9 complete. Workers, buffers, compression, and the newest protocol — the performance stack from
/proc to QUIC, every claim receipted.
Next: Module 10 — below HTTP entirely: the stream module, Layer-4 TCP/UDP proxying, TLS passthrough with SNI routing, and the PROXY protocol.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.