Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

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.

Levels: Intermediate ~35 min read

Intermediate

What you will learn

  1. Why QUIC exists: the structural problems TCP+TLS cannot solve.

  2. The configuration: the quic listener, http3 on, Alt-Svc, and the mandatory companions ( reuseport, TLS 1.3).

  3. The proof: a real --http3-only handshake, and the h2 fallback path.

  4. QUIC-specific tuning: quic_retry, 0-RTT early data, and connection migration.

  5. 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:

nginx
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
}
  1. listen … quic opens the UDP socket. reuseport is 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.

  2. 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 / http3 enable their respective listeners' protocol versions — the modern (1.25.1+) split of what listen … http2 used to do.

  3. TLS 1.3 is non-negotiable — QUIC's handshake is TLS 1.3 messages carried in UDP frames.

  4. Alt-Svc is 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 also h3-29-style draft naming history you may meet in old configs.)

  5. 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.

  6. 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_data available to apps that want to check.

3. The proof — both paths

Both directions, real handshakes:

bash
# 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 200

Note 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

quic_retry

off

on in this config — address validation, anti-amplification; slight first-connection cost (one extra round trip)

quic_stream_buffer_size

64k

per-stream buffering; raise for heavy single-stream consumers

quic_active_connection_id_limit

protocol

how many connection IDs a client may stash (migration headroom)

ssl_early_data

off

0-RTT; replay-caveated (§2.8)

keepalive_timeout

(shared)

QUIC reuses the same idle knob — after keepalive_timeout with no requests in flight, NGINX gracefully shuts the HTTP/3 connection down (NO_ERROR)

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 on qlog-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_protocol in 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

  1. Both handshakes, yourself (§3's exact commands) — then curl -V to confirm your tooling speaks h3, and try --http3 (the realistic mode): which protocol answered?

  2. 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.

  3. 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).

  4. Break the fallback. Comment out the TCP listen, reload, curl --http2 → connection refused while --http3-only still 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-Svc as the client on-ramp — all verified against a live server.

  • quic_retry is the anti-amplification valve; ssl_early_data is the replay-caveated latency win; reuseport is how workers own UDP.

  • Operations shift: connection IDs (not IPs) define connections, debugging goes encrypted-transport-shaped, UDP must be open, and $server_protocol becomes 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.

Was this page helpful?

Mark this learning resource complete to track your learning progress.