Guide
HTTP/3 and QUIC Explained: Setup, Fallback, 0-RTT
How HTTP/3 and QUIC work: UDP/443, connection IDs, 0-RTT replay risk, Alt-Svc and HTTPS DNS record discovery, nginx/Caddy/Cloudflare setup, and curl checks.
On this page
TL;DR: HTTP/3 is HTTP semantics (RFC 9114) carried over QUIC (RFC 9000) on UDP port 443 instead of TCP, which removes TCP head-of-line blocking and cuts connection setup to one round trip (zero on resumption). Clients find it through an
Alt-Svc: h3=":443"header or an HTTPS DNS record and fall back to HTTP/2 when UDP/443 is blocked, so enabling it is low risk as long as you open443/udpand keep the TCP listener.
Why QUIC exists
Two problems in the TCP-based stack could not be fixed from inside HTTP.
Head-of-line blocking at the transport layer. HTTP/2 multiplexes many streams over one TCP connection, but TCP delivers a single ordered byte stream. If one segment is lost, the kernel withholds every byte after it, including bytes belonging to unrelated HTTP/2 streams, until the retransmission arrives. HTTP/2 fixed head-of-line blocking at the HTTP layer and left it in place one layer down. QUIC gives each stream its own ordering, so a lost packet stalls only the streams whose data it carried. See HTTP/1.1 vs HTTP/2 for the layer above.
Handshake round trips. A fresh HTTPS connection over TCP needs one round trip for the TCP handshake and at least one more for the TLS 1.3 handshake before the first request can be sent. QUIC folds the transport and TLS 1.3 handshakes together, so the request can go out after one round trip. On a resumed connection a client can send a request in its very first flight (0-RTT, with the caveats below). TLS is not optional in QUIC: there is no cleartext HTTP/3.
A third reason is deployment. TCP lives in operating system kernels and is poked at by middleboxes, so changing it takes years. QUIC runs in user space on top of UDP and encrypts nearly everything, including most transport metadata, so it can evolve without waiting for every router on the path.
How the pieces fit
| Layer | HTTP/2 stack | HTTP/3 stack |
|---|---|---|
| HTTP semantics | RFC 9110 | RFC 9110 (unchanged: same methods, status codes, headers) |
| HTTP mapping | RFC 9113 | RFC 9114 |
| Header compression | HPACK | QPACK (RFC 9204) |
| Streams and loss recovery | HTTP/2 framing on one TCP stream | QUIC streams (RFC 9000) |
| Encryption | TLS over TCP | TLS 1.3 built into QUIC |
| Transport | TCP | UDP |
Application code does not change. A GET / is still a GET /; status codes, caching rules and cookies behave as before. What changes is what sits between the client and the server socket.
QPACK exists because HPACK assumed ordered delivery. HPACK’s dynamic table updates depend on the order of header blocks, which would reintroduce head-of-line blocking on independent streams. QPACK moves table updates onto dedicated unidirectional encoder and decoder streams and lets the encoder trade compression ratio against the risk of a stream blocking.
The ALPN token for HTTP/3 is h3. You will see it in Alt-Svc, in HTTPS DNS records and in packet captures.
Connection IDs and migration
A TCP connection is identified by its 4-tuple: source and destination address and port. Move from Wi-Fi to cellular and the tuple changes, so the connection dies and the client reconnects.
A QUIC connection is identified by connection IDs carried in each packet. If the client’s IP address or port changes, it can continue the same connection from the new path after the server validates that path (RFC 9000 section 9). The same mechanism copes with NAT rebinding, where a NAT silently assigns a new source port to an idle flow.
For operators this has a concrete consequence: a load balancer that hashes on the 4-tuple will send migrated packets to a backend that knows nothing about the connection. QUIC-aware balancers route on the connection ID instead. In nginx, reuseport gives each worker its own socket, and quic_bpf on; (Linux) loads an eBPF program that keeps all packets of one connection on the same worker.
0-RTT and the replay risk
On a resumed connection, a client can send application data immediately, encrypted with a key derived from the previous session. That saves a full round trip. The cost is that 0-RTT data has no replay protection at the protocol level: an attacker who captures the first flight can resend it, and the server may process the request twice.
That is harmless for GET /logo.png and dangerous for POST /transfer. The rules for a server that accepts early data:
- Treat anything that arrived in early data as replayable. Serve only requests that are safe and have no side effects.
- Reject the rest with 425 Too Early. A compliant client retries after the handshake completes, when replay is no longer possible (RFC 8470).
- When a proxy terminates QUIC, tell the upstream. nginx sets
$ssl_early_datato1for early data, so forward it:
ssl_early_data on;
proxy_set_header Early-Data $ssl_early_data;
Your backend then checks Early-Data: 1 and answers 425 for anything unsafe. If you do not have that logic, leave ssl_early_data off: you still get 1-RTT connections, which is the bigger and safer win. nginx’s QUIC documentation states that 0-RTT requires OpenSSL 3.5.1 or newer, or a library such as BoringSSL, LibreSSL or QuicTLS; the OpenSSL compatibility layer in older releases does not support it.
How clients find HTTP/3
A browser cannot simply try UDP first. The server might not speak QUIC and the network might drop UDP. So HTTP/3 is advertised, not assumed.
Alt-Svc (RFC 7838). The first connection uses TCP with HTTP/1.1 or HTTP/2. The response carries:
HTTP/2 200
alt-svc: h3=":443"; ma=86400
h3=":443" says the same origin is reachable over HTTP/3 on UDP port 443. ma is how many seconds the client may remember that. After that, the browser can use HTTP/3 on later connections. Alt-Svc: clear withdraws the advertisement. This is why the first page load in DevTools often shows h2 and a reload shows h3.
HTTPS DNS record (RFC 9460). An HTTPS resource record can advertise alpn values in DNS, so a client that supports it learns about h3 before connecting and can use it on the very first request:
example.com. 300 IN HTTPS 1 . alpn="h3,h2"
Check what you publish with dig HTTPS example.com. Not every client uses the record, so keep the Alt-Svc header as well. Some DNS providers and CDNs can publish this record for you.
Neither mechanism guarantees HTTP/3 will be used. They say it is possible; the client decides.
What breaks in practice
UDP 443 is blocked. Some corporate and public networks block or throttle UDP, and some security appliances block UDP/443 on purpose because they cannot inspect QUIC and want clients on TCP. Browsers fall back to HTTP/2, so users see no error, but they also get none of the benefits, and may see a slow first request while the QUIC attempt times out. This is why you never remove the TCP listener.
You forgot to open the port. The commonest self-inflicted problem is enabling listen 443 quic while the cloud security group, host firewall or container port mapping only allows TCP. Alt-Svc is advertised over TCP, so clients try QUIC, hit a closed UDP port, and fall back. Check each hop:
sudo ufw allow 443/udp
docker run -p 443:443/tcp -p 443:443/udp my-image
For cloud security groups and network ACLs, add a UDP 443 rule next to the TCP 443 one.
Middleboxes and NAT. UDP flows often get shorter NAT and firewall idle timeouts than TCP. QUIC has its own idle timeout and keep-alive, but an aggressive middlebox can still drop a quiet connection. QUIC also requires a client’s first packets to be padded to at least 1200 bytes (RFC 9000 section 14), so paths that drop fragments or have a small MTU can fail the handshake.
CPU cost. TCP benefits from decades of kernel and NIC offload; QUIC runs in user space, so the same traffic can cost more CPU. On Linux, nginx’s quic_gso on; enables UDP generic segmentation offload to reduce that.
Observability. tcpdump 'tcp port 443' no longer sees HTTP/3 traffic. Log the negotiated protocol ($http3 in nginx) and capture udp port 443 when debugging. Payloads are encrypted, so decrypting in Wireshark requires TLS keys exported with SSLKEYLOGFILE from the client.
Adoption. All major browsers and the large CDNs support HTTP/3. Support at the origin is far less uniform, and plenty of self-hosted stacks, load balancers and proxies are still HTTP/2 only. Putting a CDN that speaks HTTP/3 in front of an HTTP/2-only origin is a normal setup, because the visitor-to-CDN and CDN-to-origin connections are independent.
How to enable it
nginx
nginx supports QUIC and HTTP/3 from version 1.25.0, and official Linux packages include it. Check nginx -V for --with-http_v3_module if you build from source.
server {
# UDP listener for QUIC; reuseport lets every worker bind the socket
listen 443 quic reuseport;
# TCP listener for HTTP/1.1 and HTTP/2, and where Alt-Svc is delivered
listen 443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/ssl/example.com.crt;
ssl_certificate_key /etc/ssl/example.com.key;
ssl_protocols TLSv1.3; # QUIC always uses TLS 1.3; add TLSv1.2 if TCP clients need it
# Advertise HTTP/3 to clients that arrived over TCP
add_header Alt-Svc 'h3=":443"; ma=86400' always;
quic_retry on; # address validation, mitigates spoofed-source amplification
quic_gso on; # UDP segmentation offload on Linux
}
Details from the nginx documentation:
- The nginx docs say
reuseportalong withquicis what makes QUIC work properly with multiple workers. - The
http3directive defaults tooninhttpandservercontexts, so thequiclistener is the switch that matters. Sethttp3 off;to disable it for one server block. - nginx’s own example uses the same port number for HTTP/3 and HTTPS, which it recommends for better compatibility.
http2 on;replaced thehttp2parameter oflistenin nginx 1.25.1. On older builds writelisten 443 ssl http2;.$http3ish3for HTTP/3 connections and empty otherwise, which makes it a good access-log field.
Caddy
HTTP/3 is on by default: the protocols option defaults to h1 h2 h3. You only need to allow UDP 443 through the firewall. To turn it off, set protocols h1 h2 in a servers block of the global options.
Cloudflare
HTTP/3 is a per-zone setting in the Cloudflare dashboard. When enabled, Cloudflare serves HTTP/3 to visitors and advertises it with an alt-svc response header. This affects the visitor-to-Cloudflare connection only; Cloudflare’s connection to your origin is separate, so your origin does not need QUIC.
Other servers
Other servers expose HTTP/3 under their own directives and version requirements. Read your version’s documentation before copying nginx syntax.
How to verify
1. Look for the advertisement. This shows whether the server tells clients about HTTP/3:
curl -sI https://example.com | grep -i alt-svc
alt-svc: h3=":443"; ma=86400
2. Make a real HTTP/3 request with curl. Your curl must be built with HTTP/3 support; look for HTTP3 in the Features: line of curl -V. Many packaged curls are built without it, so you may need a different build.
curl -V | grep -i http3
curl -sI --http3-only https://example.com | head -1
curl -s -o /dev/null -w '%{http_version}\n' --http3-only https://example.com
--http3-only fails if QUIC does not work, which is what you want when testing a new setup. Plain --http3 tries QUIC and may fall back to an older version. The %{http_version} write-out prints 3 when HTTP/3 was used, and -v shows * using HTTP/3 and < HTTP/3 200.
3. Check the DNS record.
dig +short HTTPS example.com
4. Check in the browser. In Chrome DevTools, open the Network panel, right-click a column header and enable Protocol. HTTP/3 requests show h3. The first load may show h2 until the browser has learned the alternative service, so reload and look again.
5. Confirm from the server side. Log $http3 in nginx, and check that ss -ulpn | grep 443 shows a UDP listener.
Related
- HTTP/1.1 vs HTTP/2 for the multiplexing HTTP/3 builds on.
- HTTPS and TLS for the TLS 1.3 handshake QUIC embeds.
- 425 Too Early for the response that makes 0-RTT safe.
- Upgrade and Connection are how HTTP/1.1 negotiates protocol changes. HTTP/3 does not use them; it is discovered through Alt-Svc and DNS.
- HTTP/2 vs HTTP/3: the side-by-side decision, and how to tell QUIC from a TCP fallback.
Frequently asked questions
Is HTTP/3 faster than HTTP/2?
Not always. HTTP/3 helps most on lossy or high-latency links such as mobile networks, where a lost packet no longer stalls every stream and where connection setup takes fewer round trips. On a clean, low-latency wired connection the difference can be negligible, and QUIC can cost more CPU on the server because it runs in user space over UDP. Measure with your own traffic before treating it as a guaranteed win.
Why does my site show h2 instead of h3 in Chrome DevTools?
The first visit to an origin normally happens over TCP, because the browser has not yet learned that HTTP/3 is available. It learns that from an Alt-Svc response header or an HTTPS DNS record, caches it, and uses h3 on later connections. If it still shows h2 on later loads, check that UDP port 443 is open end to end and that the Alt-Svc header is actually reaching the client.
Does HTTP/3 use port 443 over TCP or UDP?
UDP. HTTP/3 runs over QUIC, which runs on UDP, and it normally uses the same port number as HTTPS, 443, so the firewall needs 443/udp open in addition to 443/tcp. The TCP listener keeps serving HTTP/1.1 and HTTP/2 for clients that cannot or will not use QUIC.
What happens if UDP 443 is blocked?
The browser falls back to HTTP/2 or HTTP/1.1 over TCP and the page still loads, sometimes after a short delay while the QUIC attempt fails. Corporate networks that block UDP/443 therefore degrade to h2 rather than breaking, which is why you must always keep the TCP listener alongside the QUIC one.
Is 0-RTT in HTTP/3 safe to enable?
Only if your application tolerates replayed requests. Data sent in 0-RTT can be captured and replayed by an attacker, so servers should accept it only for safe requests such as a GET without side effects, or tell the client to retry after the handshake with 425 Too Early. Pass the Early-Data header to your backend so it can make that decision.
How do I test HTTP/3 with curl?
Run curl -sI --http3-only https://example.com. This needs a curl built with HTTP/3 support, which you can check with curl -V and looking for HTTP3 in the Features line. Plain --http3 attempts QUIC and may fall back, while --http3-only fails if QUIC does not work, which is the better test for a server you just configured.
Sources
- MDN Web Docs: Evolution of HTTP (HTTP/3)developer.mozilla.org
- MDN Web Docs: Alt-Svcdeveloper.mozilla.org
- RFC 9114: HTTP/3rfc-editor.org
- RFC 9000: QUIC, a UDP-Based Multiplexed and Secure Transportrfc-editor.org
- RFC 9204: QPACK Field Compression for HTTP/3rfc-editor.org
- RFC 7838: HTTP Alternative Servicesrfc-editor.org
- RFC 9460: SVCB and HTTPS Resource Recordsrfc-editor.org
- RFC 8470: Using Early Data in HTTPrfc-editor.org
- nginx: ngx_http_v3_modulenginx.org
- nginx: QUIC and HTTP/3 supportnginx.org
- Caddy: global options (protocols)caddyserver.com
Related
HTTP/2 vs HTTP/3: TCP, QUIC and Fallback
Compare HTTP/2 over TCP with HTTP/3 over QUIC: packet loss, stream blocking, connection migration, 0-RTT replay, discovery and curl fallback checks.
425 Too Early: TLS 1.3 0-RTT Replay Protection
425 Too Early tells a client not to send a request in TLS early data because it could be replayed. See the Early-Data header and nginx config for 0-RTT.
HTTPS Explained: How TLS Secures HTTP
HTTPS is HTTP over TLS. What the TLS handshake does, how certificates prove identity, what HTTPS hides and what it does not, and how to migrate a site safely.
Connection Header
Learn how the Connection header controls whether HTTP connections stay open (keep-alive) or close after each request. Optimize with persistent connections.
Upgrade Header
Learn how the Upgrade header requests protocol upgrades to WebSocket, HTTP/2, or other protocols on the same TCP connection. Understand upgrade negotiation.