Overview
HTTP/3 has been shipping in browsers for years now, and it's still frequently mischaracterized as "HTTP/2 over UDP." That description is close enough to be misleading. QUIC — the transport underneath HTTP/3 — isn't UDP with reliability bolted on. It's a new transport protocol with reliability built in from the start, and the reason it rides on UDP is entirely about middleboxes.
Whether any of this matters for your application depends on the conditions your users are in. For most, HTTP/3 is a modest improvement. For some, it's the difference between a site that loads and a site that doesn't.
What HTTP/2 actually solved, and what it didn't
HTTP/1.1 suffered from head-of-line blocking: one slow response blocked all the responses behind it on the same connection. Browsers worked around this by opening six parallel connections per host, which is wasteful and still only gives you six.
HTTP/2 multiplexes many streams over one TCP connection. One slow response no longer blocks the others at the HTTP layer. But it's still running over TCP, and TCP doesn't know about streams — it just sees bytes. If one packet is lost, TCP holds back every stream until the retransmit arrives.
HTTP/1.1: multiple TCP connections, each blocking independently
HTTP/2: one TCP connection, HTTP-layer streams, still blocked by TCP loss
HTTP/3: one QUIC connection, transport-layer streams, loss affects only one stream
That's the whole motivation. HTTP/2 fixed head-of-line blocking at the application layer but not at the transport layer. QUIC fixes it at the transport layer.
Why UDP
The obvious question: why not build QUIC as a new transport protocol alongside TCP?
Because middleboxes. Routers, firewalls, and NAT devices in the path between client and server have been tuned for TCP and UDP for decades. A new IP protocol number would be blocked by a huge fraction of the internet.
UDP, by contrast, passes through almost everything. QUIC takes UDP as raw packet delivery and implements reliability, congestion control, and encryption on top of it, entirely in userspace. The middleboxes see UDP packets and leave them alone.
| TCP | QUIC | |
|---|---|---|
| Where implemented | Kernel | Userspace |
| Updates | OS upgrade, years | Library update, weeks |
| Encryption | TLS on top (optionally) | TLS 1.3 built in, always |
| Handshake | TCP 3-way + TLS = 2-3 RTT | 1 RTT, or 0 on resume |
| Head-of-line blocking | Yes | Per-stream only |
| Connection migration | No (tied to IP/port) | Yes (connection ID) |
The userspace implementation is a bigger deal than people realize. TCP improvements take years to reach users — the kernel has to change, distros have to ship it, and users have to upgrade. QUIC improvements ship when the server updates its library.
The handshake
HTTP/1.1 over TLS 1.3 takes three round trips to send the first byte of a response: TCP handshake (1 RTT), TLS handshake (1 RTT), HTTP request/response (1 RTT).
HTTP/3 over QUIC takes one round trip on a fresh connection, and zero on a resumed connection:
Fresh connection:
Client → Initial (contains TLS ClientHello)
Server → Initial (contains TLS ServerHello, cert, Finished)
Client → Request
Server → Response
Resumed connection (0-RTT):
Client → Initial + Request (using cached keys)
Server → Response
Zero-RTT is a significant win for repeat visitors, especially on high-latency networks. On a 100ms RTT connection, it saves 300ms on a fresh connection and 200ms on a resumed one.
The tradeoff with 0-RTT: the data sent in the first flight isn't protected against replay attacks. If an attacker captures the packets, they can resend them. For a GET request, this is fine. For a POST that creates a resource, it's not. Servers should only allow idempotent requests in 0-RTT, and most do by default.
Connection migration
QUIC connections are identified by a connection ID, not by the source IP and port. This means when your phone switches from Wi-Fi to cellular, the connection survives. TCP would drop it and require a fresh handshake.
The practical effect: a video call or download on a phone that changes networks doesn't stutter or restart. For users on trains, in cars, or moving between buildings, this is the feature that matters more than the latency savings.
Enabling HTTP/3 on Nginx
Nginx added HTTP/3 support in 1.25 as an experimental feature, requiring a QUIC-enabled build:
./configure \
--with-http_v3_module \
--with-http_v2_module \
--with-http_ssl_module \
--with-openssl=/path/to/quictls
make
sudo make install
Requires the quictls fork of OpenSSL or BoringSSL — vanilla OpenSSL doesn't expose the QUIC APIs Nginx needs.
server {
listen 443 ssl;
listen 443 quic reuseport;
http2 on;
http3 on;
ssl_certificate /etc/ssl/certs/example.com.pem;
ssl_certificate_key /etc/ssl/private/example.com.key;
# Advertise HTTP/3 availability
add_header Alt-Svc 'h3=":443"; ma=86400' always;
# HTTP/3 needs these for 0-RTT
ssl_early_data on;
quic_retry on;
ssl_protocols TLSv1.3;
}
The Alt-Svc header is what tells the browser HTTP/3 is available. On the first request over HTTP/2, the browser sees the header, remembers the endpoint, and uses HTTP/3 on subsequent requests. After a while, the browser skips HTTP/2 entirely.
Enabling HTTP/3 on Caddy
Caddy enables HTTP/3 by default. No configuration needed. Same Alt-Svc header, same port, same certificate.
This is the strongest argument for Caddy over Nginx for small deployments. The HTTP/3 story on Nginx requires a custom build and manual configuration. On Caddy it's just there.
Enabling HTTP/3 on Cloudflare
If you're behind Cloudflare, HTTP/3 is a toggle in the dashboard: Network → HTTP/3 → On. That's it. Cloudflare handles the QUIC termination and translates to HTTP/2 or HTTP/1.1 on the origin side.
Origin-side HTTP/3 is less common because Cloudflare's edge is usually close enough to the origin that the benefit is small. The client-to-edge leg is where the latency savings matter.
What to actually expect
| Scenario | HTTP/2 → HTTP/3 improvement |
|---|---|
| Fast, stable wired connection | Marginal, within noise |
| Typical home Wi-Fi | Small, maybe 5-15% on cold loads |
| Mobile network | Noticeable, especially with any packet loss |
| High packet loss (2%+) | Significant. HTTP/2 suffers badly here. |
| Network switching | Dramatic. TCP connections die; QUIC connections migrate. |
| Repeat visitor on same network | Modest, thanks to 0-RTT |
On a fast, clean connection, HTTP/3 is basically indistinguishable from HTTP/2. The performance case for it lives in the tail — users on bad connections, mobile networks, and in countries where the route to your server crosses congested links.
If your traffic is dominated by users on good connections in a few countries, HTTP/3 won't move your metrics much. If you have users worldwide, or a mobile-heavy audience, it's worth enabling.
The middlebox problem in reverse
Some networks block UDP entirely. Corporate firewalls, some public Wi-Fi, and a small percentage of home routers drop UDP 443 without explanation.
This is why HTTP/3 is always deployed alongside HTTP/2 and HTTP/1.1, never as a replacement. The client tries HTTP/3 based on the Alt-Svc header, and if the connection fails, it falls back. Modern browsers implement this fallback aggressively — one failure and they won't try HTTP/3 for that origin for a while.
# Caddy example: HTTP/3 with HTTP/2 fallback
example.com {
reverse_proxy localhost:3000
}
# Caddy serves h3, h2, and http/1.1 on the same port automatically
The fallback is automatic on the server side and automatic on the client side. You don't write code for it; you just don't disable HTTP/2 or HTTP/1.1.
Debugging
Chrome DevTools shows the protocol per request in the Network tab. In the Protocol column, you'll see h3, h2, or http/1.1.
If you're not seeing h3:
- Check the
Alt-Svcheader is being sent:curl -I https://example.com - Check UDP 443 isn't blocked:
nc -vzu example.com 443 - Check the server's QUIC support:
curl --http3 -I https://example.com - Chrome caches the "HTTP/3 doesn't work for this origin" decision. Clear it at
chrome://net-internals/#http3.
# Check what Alt-Svc advertises
curl -sI https://www.google.com | grep -i alt-svc
# alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
# Force curl to use HTTP/3
curl --http3 -I https://www.cloudflare.com
What's next for QUIC
HTTP/3 is the most visible use, but QUIC is being adopted elsewhere:
| Protocol | Status |
|---|---|
| DNS over QUIC (DoQ) | Standardized, limited adoption |
| MQTT over QUIC | Draft, used in some IoT platforms |
| SMB over QUIC | Windows Server 2022, remote file access |
| WebTransport | Browser API for bidirectional streams over HTTP/3 |
WebTransport is the one worth watching. It's positioned as a replacement for WebSockets and WebRTC data channels, with better support for unreliable streams and multiple concurrent streams over one connection. Browser support is still limited, but if you're building real-time applications and can afford to wait, it's where things are heading.
Should you enable it?
If you're on Cloudflare, Caddy, or any modern cdn — yes, just turn it on. The cost is zero and the benefit is positive for some fraction of your users.
If you're on a self-managed Nginx, the cost is a custom build and an upgrade path you have to maintain. Worth it if you have a global or mobile-heavy audience. Skip it if your users are on fast connections in one region.
If you're on a load balancer that doesn't support QUIC (most traditional ones), you can terminate HTTP/3 at a CDN and keep HTTP/2 between the CDN and your origin. The latency savings are mostly on the client-to-edge leg anyway.
For a technology that sounds exotic, the practical advice is boring: it's an incremental improvement, it's already on by default in a lot of places, and the fallback story is well-handled. Enable it where it's cheap, don't fight infrastructure to get it, and check the Protocol column in DevTools once to confirm.
