HTTP/1.1 vs HTTP/2 vs HTTP/3 on Your Server
Three version numbers in one nginx config. What HTTP/2 multiplexing and HTTP/3 over QUIC actually change, how to enable each, and when it is worth it.
HTTP/1.1 vs HTTP/2 vs HTTP/3: what actually changed
HTTP/1.1, HTTP/2 and HTTP/3 carry the same requests and the same responses. A GET, a 404, a Content-Type header: all three versions mean the same thing by them. What changed underneath is how many requests can be in flight on one connection, and what happens to the rest of them when a single packet goes missing.
HTTP/2 puts many requests on one TCP connection at the same time. HTTP/3 keeps that and moves the whole thing onto QUIC, a transport that runs over UDP (user datagram protocol), so one lost packet stalls one response instead of all of them. The operator summary: HTTP/2 is one directive on a TLS listener you already have, so turn it on. HTTP/3 is a separate decision that depends on your nginx build and on your firewall.
If the request and response side of this is new, what an HTTP request and response actually contain covers the semantics that did not change. Both newer versions are TLS-only in browsers, which is one more reason HTTPS is the baseline on a public server.
Why HTTP/1.1 needs six connections
An HTTP/1.1 connection carries one request at a time. The client sends a request, waits for the entire response, then sends the next one. Pipelining was meant to fix this by letting the client send several requests without waiting, but responses still had to come back in the order they were asked for, so one slow response blocked every response queued behind it. Browsers disabled it. That is head-of-line blocking at the HTTP layer, and it is the defining limit of HTTP/1.1.
Browsers worked around it by opening more connections, up to about six per origin. Six connections means six TCP handshakes and six TLS handshakes, and each connection starts with its own small congestion window, so none of them reach full speed during a short page load.
That six-connection ceiling is the reason for a generation of front-end tricks: assets sharded across img1.example.com and img2.example.com to buy another six connections per hostname, icons glued into one sprite image, CSS inlined into the HTML, scripts concatenated into a single file. Each of those was a correct fix for a real limit. Most of them cost you something once the limit is gone.
What HTTP/2 fixed, and what it inherited
HTTP/2 multiplexes. Every request and its response is a stream, each stream is cut into frames, and frames from different streams interleave on one connection. The browser can ask for forty assets at once and the server can start answering all of them, over one handshake and one congestion window that actually gets up to speed.
It also compresses headers, with a scheme called HPACK. HTTP/1.1 sends the full header block as plain text on every single request, and a cookie of any size is repeated in every one of them. HPACK keeps a table of header fields already sent on that connection, so later requests reference an index instead of resending the bytes.
Server push was part of the original HTTP/2 pitch and it is gone. Chrome removed support for it, and nginx removed its push directives in 1.25.1. Do not build anything on it.
Here is what HTTP/2 could not fix: it still runs on TCP, and TCP delivers one ordered byte stream. When a segment is lost, the kernel holds every byte that arrived after it until the retransmission lands, because it is not allowed to hand the application bytes out of order. All forty multiplexed streams stall, including the thirty-nine whose data is already sitting in the receive buffer. That is TCP head-of-line blocking, and on a lossy link it is worse than what it replaced: with six connections a loss stalls one sixth of the page, and with one connection it stalls the page.
On a clean wired link with almost no loss this is invisible, which is why HTTP/2 looks like a free win from a desktop on fibre. On a mobile network with a percent or two of loss, it is the dominant effect.
Why HTTP/3 moved to UDP
HTTP/3 is the same stream model on a different transport, called QUIC. It runs over UDP for one reason: UDP is the transport that the routers and firewalls between you and your users will forward without trying to interpret it. TCP cannot be changed in practice, because middleboxes inspect and rewrite TCP options, and a connection using anything unfamiliar gets dropped or mangled in transit. QUIC lives in user space above UDP, so a browser and a server can ship a new version of it without waiting for the world's kernels.
Because QUIC tracks streams itself instead of leaning on TCP's single byte stream, a lost packet holds up only the streams that had bytes in that packet. The other responses keep being delivered. That is the whole point of HTTP/3. Everything else is a bonus.
The bonuses are worth knowing:
- TLS 1.3 is part of QUIC rather than a layer stacked on top of it, so the transport handshake and the cryptographic handshake are the same exchange. That is where the round trip saving comes from.
- A QUIC connection is identified by a connection ID, not by the source address and port. A phone moving from Wi-Fi to mobile data changes its address, and the QUIC connection survives it. The TCP connection does not.
- Header compression is QPACK rather than HPACK, because HPACK's shared table assumes headers arrive in order, and out-of-order delivery is exactly what QUIC exists to allow.
- Almost the entire packet is encrypted, transport headers included. Middleboxes cannot read or rewrite it. Some networks answer that by blocking UDP 443 outright, which is a real failure mode further down.
Handshake round trips, counted
The data behind this chart
[
{
"protocol": "HTTP/1.1 over TLS 1.3",
"round_trips_before_first_request": 2,
"connections_for_6_parallel_requests": 6
},
{
"protocol": "HTTP/2 over TLS 1.3",
"round_trips_before_first_request": 2,
"connections_for_6_parallel_requests": 1
},
{
"protocol": "HTTP/3 over QUIC",
"round_trips_before_first_request": 1,
"connections_for_6_parallel_requests": 1
}
]A new connection over TCP and TLS 1.3 costs 2 round trips before the client can send the first byte of a request, and that is true for HTTP/1.1 and HTTP/2 alike. QUIC folds the two handshakes into one and costs 1. On a link with 30 ms of latency that saves 30 ms, once, at the start of a connection. It is real and it is small.
The concurrency column is the larger difference. Six parallel requests need 6 separate connections under HTTP/1.1 and 1 under HTTP/3. These are counts derived from protocol behaviour and browser policy, not timings from a benchmark. What you measure depends on your latency, your loss rate and your page.
How these counts are derived
- The TCP handshake is SYN, SYN-ACK, ACK. The client can put application data on that final ACK, so it costs one round trip.
- A full TLS 1.3 handshake is ClientHello, then ServerHello with the server's Finished, then the client's Finished. The client sends its request alongside that Finished, so it costs a second round trip.
- QUIC carries the TLS 1.3 handshake inside its own Initial packets. There is one handshake instead of two, so the request goes out after one round trip.
- Six connections is the per-origin limit that mainstream browsers apply to HTTP/1.1. It is browser policy, not a number from the HTTP specification.
- Session resumption changes all of this. A resumed TLS 1.3 or QUIC connection can send early data in its first flight, at zero round trips, with the replay risk that early data always carries.
Turning on HTTP/2 in nginx
Find out what your binary can do before you edit anything. The version number will not tell you, and neither will the package name.
nginx -v
nginx -V 2>&1 | tr ' ' '\n' | grep -E 'http_v2|http_v3|openssl'nginx -V writes to standard error, which is why 2>&1 is there. It prints the configure arguments the binary was built with. HTTP/2 requires --with-http_v2_module and HTTP/3 requires --with-http_v3_module, and neither module is built by default from the nginx source. Whether you have them is a property of whoever built your package, so read that vendor's own documentation. If the flag is absent from the output, no directive will turn the feature on and you need a different build.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}sudo nginx -t && sudo systemctl reload nginx
curl -sI --http2 https://example.com/ | head -1nginx -t should report that the syntax is ok and the test is successful, and the curl line should print HTTP/2 200.
http2 on; is the current form and it arrived in nginx 1.25.1. Builds older than that take the listen parameter instead, listen 443 ssl http2;. Getting this wrong is loud rather than silent. An older binary given http2 on; refuses to start with nginx: [emerg] unknown directive "http2", and a current binary given the listen parameter starts but warns that the parameter is deprecated and points you at the directive.
The certificate is not optional here. Browsers select HTTP/2 through ALPN (application layer protocol negotiation), which happens inside the TLS handshake, so a plain port 80 server stays on HTTP/1.1 whatever the config says. If you do not have a certificate yet, issuing a Let's Encrypt certificate with Certbot on nginx is the shortest route to one.
Turning on HTTP/3 in nginx
The config is the easy part.
server {
listen 443 quic reuseport;
listen 443 ssl;
listen [::]:443 quic reuseport;
listen [::]:443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
add_header Alt-Svc 'h3=":443"; ma=86400' always;
}
}The quic listener adds to the TLS listener, it does not replace it. HTTP/3 is advertised rather than required, and every client that cannot speak QUIC still arrives over TCP. Use the same port number for both, which is what the nginx documentation recommends, because clients handle a different HTTP/3 port poorly.
If the build has no HTTP/3 module, this fails at the config test with nginx: [emerg] invalid parameter "quic". That is the module missing rather than a typo, and the fix is a different binary.
reuseport may appear on only one listen directive per address and port in the whole configuration. Put it on a second server block listening on the same address and nginx refuses to start, complaining about duplicate listen options for that address and port.
Alt-Svc is how a browser learns that HTTP/3 exists. The first request arrives over HTTP/2, the response carries Alt-Svc: h3=":443"; ma=86400, and the browser uses HTTP/3 for later connections to that origin for the next ma seconds, 86400 being one day. Without that header nothing switches. The always flag matters because nginx add_header applies only to a short list of status codes without it, so redirects and error pages would carry no advertisement. If the port inside the value does not match your quic listener, browsers try a closed port and fall back quietly forever.
Then the firewall, which is where this usually stops.
sudo ufw allow 443/udp
sudo ufw status
sudo ss -ulpn | grep ':443'QUIC is UDP, so a rule allowing 443/tcp does nothing for it. ss -ulpn lists UDP sockets, and nginx should appear bound to 443. Most hosts also have a network firewall in the control panel, separate from anything running on the box, and UDP 443 has to be open there too. If ufw is new to you, the ufw firewall basics for a VPS covers rules and how to read that status output.
One more build detail: as of September 2026 the nginx documentation states that 0-RTT under QUIC needs OpenSSL 3.5.1 or newer. Older builds still do QUIC through a compatibility layer, with ordinary 1-RTT handshakes. nginx -V shows which TLS library your binary was built against.
Who terminates the connection when a CDN is in front
If a CDN or a load balancer sits in front of your server, the protocol version your visitors use is decided there, not on your box. The browser opens a connection to the edge. The edge opens a separate connection to your origin. Two connections, negotiated independently, with no requirement that they match.
So your visitors may already have HTTP/3 without you touching anything. If the edge advertises Alt-Svc with h3, browsers are speaking QUIC to it today. Test the public hostname, not the origin, and you will see which one you have.
Enabling HTTP/3 on an origin behind that edge changes only the edge-to-origin hop, and most edges do not use it there. nginx shows the general shape of the problem: proxy_pass speaks HTTP/1.0 by default and HTTP/1.1 once you set proxy_http_version 1.1;, and those are the only two values that directive accepts. An nginx reverse proxy in front of your application talks HTTP/1.1 to the backend no matter what the browser used out front. How an nginx reverse proxy config is put together walks through that hop.
The rule that follows: enable the new protocol on the hop that terminates TLS for real browsers, because that is the only hop where multiplexing and loss recovery reach a user. If your VPS is that hop, this is your job. If a CDN is, your origin protocol is an internal detail on a short, well connected, low loss link, which is precisely where QUIC has the least to offer.
How to check which protocol was actually used
curl -sI --http2 https://example.com/ | head -1
curl -sI --http3-only https://example.com/ | head -1
curl -V | grep -i featuresThe first line of each response says it plainly: HTTP/2 200 or HTTP/3 200. Use --http3-only rather than --http3 when testing, because --http3 falls back to an earlier version on failure and hands you a success you did not earn.
curl -V matters because HTTP/3 is a build-time feature in curl as well. The Features: line lists HTTP2, and on a build that has it, HTTP3. If HTTP3 is not in that list, --http3-only fails inside curl and tells you nothing at all about your server.
On the server side, log the protocol so you can watch the real mix instead of guessing.
log_format quic '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" "$http3"';
access_log /var/log/nginx/access.log quic;$http3 is h3 on an HTTP/3 request and empty otherwise. Run that for a week and you will know what share of your traffic uses it. In a browser, the network panel of the developer tools has a protocol column showing h2 or h3, though you may have to switch the column on.
Expect the first request from a browser to show h2 even on a working setup. Browsers do not attempt HTTP/3 on a first connection to a hostname. They learn about it from the Alt-Svc header, or from an HTTPS DNS record carrying alpn="h3" if you publish one, and use it on the next connection.
What breaks, and what you will see
nginx: [emerg] unknown directive "http2". The binary predates 1.25.1, or it was built without --with-http_v2_module. Check nginx -V, then use listen 443 ssl http2; on a build that is merely old.
nginx: [emerg] invalid parameter "quic". The binary was built without --with-http_v3_module. No directive fixes this. You need a build that includes the module.
nginx refuses to start over duplicate listen options for 0.0.0.0:443. reuseport is set on more than one listen for the same address and port. Keep it on exactly one.
The Alt-Svc header is served and browsers stay on h2. Something in the path is advertising a port or a protocol that is not reachable. Confirm nginx is bound with sudo ss -ulpn | grep ':443', confirm UDP 443 is open on the host firewall and in your provider's panel, then confirm the port inside the header value matches the quic listener. Some corporate and mobile networks block UDP 443 entirely, and the correct browser behaviour there is to keep using HTTP/2 without telling anyone.
Everything got slower after HTTP/2 went on. Look for HTTP/1.1 era workarounds still in place. Assets sharded across several hostnames force several connections, which splits the multiplexing and throws away the shared header compression table. One origin, one connection.
HTTP/3 works and CPU use went up. QUIC runs in user space, so the kernel TCP stack and any TLS offload are out of the picture and the per-byte cost is higher than TCP plus kernel TLS. Published measurements have reported this consistently. Measure your own box under your own load before you decide the difference does not matter to you.
Is HTTP/3 worth enabling on a small site?
HTTP/2: yes, with little thought. It is one directive on a TLS listener you already run, every browser in use supports it, and the failure mode is a config test error rather than a broken site. The only real work is removing the HTTP/1.1 workarounds that now cost you.
HTTP/3: it depends on your build and on what sits in front of you. Do it if your binary already has --with-http_v3_module, you control both firewalls, and a real share of your traffic is mobile, because packet loss is where QUIC earns its keep. Skip it if getting the module means compiling nginx yourself for a site that does not need it, and skip it if a CDN terminates for your visitors, because they have HTTP/3 already and your origin is not the hop that decides.
And keep the size of the prize in view. HTTP/3 does not fix a slow database query, an uncached response, an oversized JavaScript bundle, or a server on the wrong continent from its readers. If a page takes 900 ms to generate, the one round trip you saved on the handshake is noise.
If you are choosing a front door rather than tuning one you already have, some servers decide this for you. Caddy's default protocol list is h1 h2 h3, so HTTP/3 is on unless you switch it off. Caddy and Traefik set next to nginx covers what else changes with that choice.
FAQ
Do I need HTTPS to use HTTP/2 or HTTP/3?
In practice, yes. No mainstream browser negotiates HTTP/2 without TLS, because it selects the version through ALPN inside the TLS handshake. HTTP/3 goes further: TLS 1.3 is built into QUIC itself, so an unencrypted HTTP/3 does not exist. nginx will serve cleartext HTTP/2 to a client that insists, which curl can do with --http2-prior-knowledge, but no browser will ask.
Why does my browser still show h2 after I enabled HTTP/3?
Browsers do not try HTTP/3 on the first connection to a hostname. They learn about it from the Alt-Svc response header or from an HTTPS DNS record, then use it on a later connection. Check the header is present on the response, including on redirects and error pages, which on nginx needs always on the add_header line. If the header is right, the next suspect is UDP 443: blocked on the host firewall, blocked in your provider's network firewall, or blocked by the network the browser is sitting on.
How do I know whether my nginx supports HTTP/2 and HTTP/3?
Run nginx -V 2>&1 | tr ' ' '\n' | grep http_v. Seeing --with-http_v2_module means HTTP/2 is available and --with-http_v3_module means HTTP/3 is. Neither is built by default from source, so what you have depends on who built your binary. Read the documentation of whoever ships your package instead of assuming a version number implies support.
Should I turn on HTTP/3 if my site is behind a CDN?
It changes almost nothing for your visitors. The browser negotiates with the CDN edge, and the edge opens its own connection to your origin, usually HTTP/1.1 or HTTP/2 with keepalive. Your visitors get HTTP/3 when the edge offers it, whatever the origin does. Enabling it at the origin affects only a hop that is normally short and low loss, which is where QUIC's advantage is smallest.