HTTP/3 runs QUIC over UDP:443, and the reason to care is what a lost packet costs. On a TCP connection every stream shares one byte stream, so a dropped segment blocks delivery of everything behind it until the retransmission arrives. QUIC gives each stream its own loss recovery, so the same drop blocks only the streams whose data was in that packet.

The awkward part is that this is not a flag you can turn on: the client must learn the server speaks HTTP/3 before it can try, and the path it learns that over is the one you were trying to improve.

Two transports, one port number

UDP:443 and TCP:443 are separate namespaces, so a single listener can hold both. That is why an nginx server block can carry listen 443 quic reuseport; next to listen 443 ssl; http2 on; and serve both on one port number. The reuseport matters more than it looks: without it, a second bind of UDP:443 fails with EADDRINUSE, which is the error you get when two server blocks both claim the QUIC listener.

QUIC is not HTTP/2 moved onto UDP. It folds the transport and TLS handshakes into one flight — one round trip, zero on a resumed session — and carries a connection ID independent of address and port. That ID is what makes connection migration possible, and it is also what breaks load balancers that assume a flow stays on one 5-tuple.

Alt-Svc is how the client finds out

The first connection to an origin is always over HTTP/2 on TCP, because the client has no idea yet. The response carries an advertisement: Alt-Svc: h3=":443"; ma=86400. Parsed, that is protocol h3, authority :443 and a freshness lifetime of 86400 seconds, or 24 hours. On the next connection to that origin the client tries QUIC first, so the upgrade lands on a subsequent visit and never on the first one.

ma is the part that bites. While the advertisement is fresh, the client tries HTTP/3 first on every new connection — including after HTTP/3 has failed repeatedly. Model UDP:443 silently dropped by a middlebox: the client burns 15 seconds in QUIC probe-timeout backoff (1, 2, 4 then 8 seconds) before falling back to HTTP/2. At 20 visits a day that is 300 wasted seconds daily until the cache expires a day later. Start the TCP connection in parallel instead of waiting for QUIC to give up, and the identical blocked path resolves in 0.1 seconds. The fallback penalty is a design choice, not a protocol cost.

0-RTT and migration are the two sharp edges

0-RTT lets a returning client send application data in the very first flight, using a ticket from the previous session. That data is replayable — anyone who captures the flight can send it again — so only requests that are safe to repeat belong in it. A “create order” call in 0-RTT is a duplicate order waiting for a reason.

Connection migration is the other edge. Because QUIC keys the connection on its connection ID rather than the address, a client that roams from Wi-Fi to cellular keeps the same connection. Put a load balancer that hashes the 5-tuple in front of that and the continued connection lands on a different backend: with eight backends, a model of the same client before and after roaming picks backend 7 and then backend 4. The protocol’s mobility becomes the load balancer’s problem.

When it breaks

The first failure is an Alt-Svc cached on the client while the network drops UDP. It presents as a slow first byte on every navigation for a whole day, with nothing unusual in the server log — the QUIC Initials never arrive, so there is nothing to log. Compare server-side HTTP/3 connection counts against client-perceived latency; a wide gap with a healthy h2 path is the signature. Shorten ma, race the TCP connection, and do not advertise h3 on paths you cannot verify.

The second failure is connection migration breaking session affinity. A mobile client changes address mid-session, HTTP/3 keeps the connection alive, and the backend that now owns it has none of the cache or session state the old one had. It presents as intermittent auth failures, cache misses and half-completed uploads, only ever on mobile clients, which is what makes it hard to reproduce. Route on connection ID, or pin affinity to something that survives the address change.

Check it quickly

Confirm both paths are actually alive:

curl -sI https://example.com/ | grep -i alt-svc
curl -s --http3-only -o /dev/null -w '%{http_version} %{time_appconnect}\n' https://example.com/
nginx -T 2>/dev/null | grep -nE 'listen .*quic|http3|Alt-Svc'
ss -lun | grep -E ':443\b'

The first should print an h3 advertisement with a bounded ma. The second prints 3 and a small connect time if UDP works, or fails outright if the path blocks it — a failure here while the first succeeds is the middlebox case. The third shows whether the config actually wires QUIC and the header together, and the fourth proves a UDP socket is bound on 443.

Sources