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

Source: https://howhttpworks.com/guides/http3-and-quic
Last reviewed: 2026-10-04

> **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 open `443/udp` and 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](https://howhttpworks.com/compare/http1-vs-http2) 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](https://howhttpworks.com/status-codes/425). 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_data` to `1` for early data, so forward it:

```nginx
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
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:

```text
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:

```bash
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.

```nginx
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 `reuseport` along with `quic` is what makes QUIC work properly with multiple workers.
- The `http3` directive defaults to `on` in `http` and `server` contexts, so the `quic` listener is the switch that matters. Set `http3 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 the `http2` parameter of `listen` in nginx 1.25.1. On older builds write `listen 443 ssl http2;`.
- `$http3` is `h3` for 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:

```bash
curl -sI https://example.com | grep -i alt-svc
```

```text
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.

```bash
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.**

```bash
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](https://howhttpworks.com/compare/http1-vs-http2) for the multiplexing HTTP/3 builds on.
- [HTTPS and TLS](https://howhttpworks.com/guides/https-and-tls) for the TLS 1.3 handshake QUIC embeds.
- [425 Too Early](https://howhttpworks.com/status-codes/425) for the response that makes 0-RTT safe.
- [Upgrade](https://howhttpworks.com/headers/upgrade) and [Connection](https://howhttpworks.com/headers/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](https://howhttpworks.com/compare/http2-vs-http3): the side-by-side decision, and how to tell QUIC from a TCP fallback.
