Home Server & Self-Hosting

HTTP/3 and QUIC on a Small Server — What Changes, What It Costs, and When to Skip It

HTTP/3 and QUIC on a Small Server — What Changes, What It Costs, and When to Skip It

There is a version of this question that sounds obvious and is not: HTTP/3 has been a published IETF standard since June 2022, most browsers speak it, and roughly a quarter of the web already advertises it — so why does almost nobody running their own small server turn it on?

I wanted an honest answer, so I started with the least impressive experiment available: I stood up HTTP/3 on this machine, requested a 56-byte HTML file over it, and looked at the result. It worked, and it taught me almost nothing about performance, which turned out to be the interesting part. What follows keeps three things apart: what the specifications say, what serving HTTP/3 costs, and what anyone can honestly claim about speed.

What HTTP/2 could not fix

HTTP/2 multiplexes many requests over a single TCP connection, which removed the old problem of opening six connections for six files. It did not remove the transport's own ordering rule. RFC 9114 states the problem plainly: because the parallel nature of HTTP/2's multiplexing is not visible to TCP's loss recovery mechanisms, a lost or reordered packet causes all active transactions to experience a stall, regardless of whether that transaction was directly impacted by the lost packet.

In plain language: if one packet in the middle of the connection is lost, TCP cannot deliver the packets behind it until the gap is repaired, even when those packets belong to unrelated requests. On a fast, quiet connection you will never notice. On a lossy mobile link, a page with a dozen subresources can wait on one missing packet.

What QUIC actually changes

RFC 9000 defines QUIC as a UDP-based multiplexed and secure transport, and its abstract lists what applications get: flow-controlled streams, low-latency connection establishment, and network path migration. RFC 9001 adds that the confidentiality, integrity, and peer authentication come from TLS 1.3 carried inside QUIC rather than layered on top of TCP. Its section 2 is blunt about what that acronym means: "the acronym TLS is used to refer to TLS 1.3", and TLS is what "enables authentication of peers and provides confidentiality and integrity protection for messages that endpoints exchange".

The three practical consequences:

  • Loss is contained per stream. A lost packet stalls the stream that needed it, not the whole connection. This is the head-of-line problem above, solved at the transport level.
  • A connection is identified by a connection ID, not by an IP address and port pair. RFC 9000 section 9 explains that this lets a connection survive address changes, and that a server must validate a new peer address before sending non-probing packets to it. NAT rebinding, where a middlebox hands the client a new port mid-connection, is handled as a path change rather than a broken connection.
  • Handshake and TLS are one thing. There is no TCP three-way handshake before the TLS handshake; the protocol is selected during it with the ALPN token "h3".

What does not change matters just as much for anyone running a site. HTTP/3 is the same HTTP: method, path, status codes, headers, cookies, caching, compression, and your application code all behave as before. The differences that will surprise you are mostly subtractive, as listed in RFC 9114 — Transfer-Encoding "MUST NOT be used" (section 4.1), the Connection header field is not used and any message carrying connection-specific fields "MUST be treated as malformed" (section 4.2), the HTTP Upgrade mechanism and the 101 status code are gone (section 4.5), and a server "is not able to push until after the client sends a MAX_PUSH_ID frame" (section 4.6). If your stack quietly relies on any of those, that is where to look.

What it takes to serve HTTP/3

The nginx documentation is refreshingly direct. The ngx_http_v3_module page states that the module arrived in 1.25.0, is not built by default, needs OpenSSL 1.1.1 or newer, and — in the module's own "Known Issues" section — that "the module is experimental, caveat emptor applies". The listen directive gained a quic parameter in the same version, while the older http2 parameter is deprecated in favour of the http2 directive.

So the requirements are: a build with the module, a certificate, the same port served over TCP and UDP, and a firewall that lets UDP through. The module's own example configuration notes that using the same port for HTTP/3 and HTTPS is recommended for compatibility, and it adds the advertisement header by hand:

server {
    listen 8443 quic reuseport;
    listen 8443 ssl;

    ssl_certificate     certs/example.com.crt;
    ssl_certificate_key certs/example.com.key;

    location / {
        # used to advertise the availability of HTTP/3
        add_header Alt-Svc 'h3=":8443"; ma=86400';
    }
}

That add_header line is the part people miss. HTTP/3 has no in-band upgrade path: a client that has never seen an advertisement will not guess. RFC 9114 section 3.1.1 describes the mechanism, and RFC 7838 defines the Alt-Svc field itself. I checked this on the test server rather than assuming it: I ran the same nginx twice, once with the add_header line and once without, and the header was absent in the second case on both HTTP/2 and HTTP/3 responses. nginx does not invent it for you.

What I could and could not observe here

To avoid disturbing the site that serves this article, I started a second nginx instance with its own prefix, its own temporary certificate, and port 9443 on loopback. The server block below is the one that ran, with the certificate paths pointing at a throwaway lab directory:

server {
    listen 9443 ssl;
    listen 9443 quic reuseport;
    http3 on;
    http2 on;
    server_name quic.lab;

    ssl_certificate     /home/adam/working/nginx-h3-lab/certs/cert.pem;
    ssl_certificate_key /home/adam/working/nginx-h3-lab/certs/key.pem;
    ssl_protocols TLSv1.3;

    root /home/adam/working/nginx-h3-lab/html;
    index index.html;
    add_header Alt-Svc 'h3=":9443"; ma=86400';
}

On 28 September 2026 this machine ran nginx 1.26.3 built with OpenSSL 3.5.7, compiled with --with-http_v3_module, and curl 8.14.1 built with nghttp3. A forced HTTP/3 request returned these response headers, with the date, last-modified, etag, and accept-ranges lines removed for brevity:

curl -sSk --http3-only --resolve quic.lab:9443:127.0.0.1 https://quic.lab:9443/ -D - -o /dev/null
HTTP/3 200
server: nginx/1.26.3
content-type: text/html
content-length: 56
alt-svc: h3=":9443"; ma=86400

The same server on the same port answered HTTP/2 and HTTP/1.1 as well. With a log format that includes the module's $http3 variable, all three requests were distinguishable in one file:

127.0.0.1 "GET / HTTP/3.0" 200 "curl/8.14.1" http3=h3
127.0.0.1 "GET / HTTP/2.0" 200 "curl/8.14.1" http3=
127.0.0.1 "GET / HTTP/1.1" 200 "curl/8.14.1" http3=

Before I say anything about speed, here is what this does and does not prove. It proves the plumbing works: handshake, ALPN, framing, and logging. It proves nothing about latency for real visitors, because the client and the server were on the same machine over loopback. The connection time I measured ranged from about 6 to 13 milliseconds across runs, which measures my loopback and nothing else. I ran no wide-area benchmark, and I am not going to invent one.

0-RTT: the tempting part and the dangerous part

QUIC can let a returning client send a request in its very first flight, using a previously issued session ticket. The specification is blunt about the catch. In its TLS overview, RFC 9001 notes that this early data "can be replayed by an attacker, so 0-RTT is not suitable for carrying instructions that might initiate any action that could cause unwanted effects if replayed", and section 9.2, titled "Replay Attacks with 0-RTT", adds that while endpoints "MUST implement and use the replay protections described in [TLS13]", these protections "are imperfect".

That maps cleanly onto a practical rule: early data is acceptable for requests that are safe to repeat, and not acceptable for anything that changes state. A replayed "create an order" is a different situation from a replayed "fetch the article".

I could not test 0-RTT here, and the reason is instructive. The curl build on this machine has no option to send early data, the nginx documentation says 0-RTT support requires OpenSSL 3.5.1 or newer, and that "Before version 1.29.1, 0-RTT support could not be enabled with OpenSSL regardless of the ssl_early_data directive value". This server runs 1.26.3. The advanced features are exactly the ones that are hardest to enable, which is a fair summary of the whole experience.

The operational details nobody puts in a tutorial

  • Address validation and amplification limits. RFC 9000 section 8.1 requires that, before a client address is validated, a server "MUST NOT send more than three times as many bytes as the number of bytes they have received", and clients must pad Initial datagrams to at least 1200 bytes. A small VPS that suddenly answers with large responses to unknown addresses is the thing this rule exists to prevent. nginx exposes it as quic_retry on;.
  • Stateless reset keys. the module's quic_host_key directive documents that "By default, a random key is generated on each reload. Tokens generated with old keys are not accepted." A reload is not a no-op for QUIC state; tokens in flight can become invalid.
  • Connection migration is not free. Migration needs the server to keep connection IDs per client and route returning packets; the module's quic_bpf directive, which enables eBPF-based routing and is documented as supporting QUIC connection migration, requires Linux 5.7 or newer.
  • Monitoring changes shape. Everything you were watching was TCP. QUIC is UDP, and ordinary connection-level tooling does not read it, so the first symptom of a QUIC problem is often just "requests fell back to TCP and nobody knows why".
  • Fallback is required, but not free. RFC 9114 section 3.1 says clients SHOULD fall back to TCP-based HTTP when a QUIC connection cannot be established, for example when UDP is blocked. HTTP Archive's Web Almanac chapter on HTTP points out the asymmetry: a browser that finds no HTTP/2 support keeps using the connection it already has, but a failed QUIC attempt has to time out first. Keep the TCP listener healthy in parallel, and treat "QUIC-only" as a broken site rather than a clean switch.

Does it actually make pages faster?

The specification gives reasons to expect improvement: contained packet loss, fewer round trips, and connections that survive a network change. The reality seems more conditional. Dropbox ran a two-week experiment from December 2022 to January 2023, firing about 300,000 HTTP/3 requests per day, and reported the results on their engineering blog: for the majority of users the reduction was 5 to 15 milliseconds, around 5 percent and "negligible to the average user", while p90 improved by 48 milliseconds and p95 by 146 milliseconds. Their conclusion was that eliminating head-of-line blocking mattered far more than 0-RTT, and that the gains concentrated in the users already having a bad time. That is a single company's network, and I am quoting it as one data point, not as a general law.

Adoption data tells a complementary story. The Web Almanac chapter, written by Robin Marx and published in December 2024 with a January 2025 update, reports home pages announcing HTTP/3 through alt-svc rising from about 18% in 2022 to 26% on desktop and 28% on mobile in 2024 — while only around 7% to 9% of pages were actually loaded over HTTP/3, and roughly 85% of HTTP/3 responses came from a CDN, compared to about 55% of what the chapter groups as HTTP/2+ requests. Read together: the protocol is spreading, and it is spreading mostly through edge networks rather than through people who configured an origin.

My honest reading: for a small site, the protocol change itself is probably not where your performance problem lives. Caching, image weight, the number of round trips your HTML forces, and the quality of the path to the visitor usually dominate. HTTP/3 is a real improvement with a real price tag, and on a home connection with a handful of readers the price may never be repaid.

When to skip it

The strongest case for skipping it is architectural, and easy to check. The site that serves this article is a useful example: its nginx vhost listens on port 80 and 443, and its public hostname is handled by a Cloudflare tunnel whose configuration points at http://localhost:80. Public TLS, and any HTTP/3 at all, belongs to the edge. Enabling QUIC on that origin would change nothing for visitors while adding a UDP listener, a second code path to test, and a new failure mode — for no user-visible benefit.

It is also reasonable to skip it when your distribution ships no module, when you cannot open UDP on the port, when you have no way to tell QUIC traffic from TCP traffic in your logs, or when a CDN already terminates HTTP/3 for you.

If you do enable it, a conservative order looks like this: keep TCP working first; add the QUIC listener on the same port; add Alt-Svc with a modest ma value so the advertisement expires if something breaks; log the protocol so you can see it. Removing the QUIC listener and reloading is a complete rollback, because clients fall back to the TCP path you never turned off.

What I still cannot tell you

I do not know how HTTP/3 behaves on the specific networks my readers use, and the honest answer to "should I turn it on?" depends on that, on the CPU budget of the origin, and on whether anything between me and the visitor discards UDP. The narrower claim I can support is this: the protocol is a carefully specified answer to a real limitation in HTTP/2, and enabling it is deliberate, reversible work. On the version question, the Web Almanac chapter has a pointed observation — a lot of popular off-the-shelf web servers "do not have stable, mature, on-by-default HTTP/3 support yet", naming nginx among them — which matches what I found here: 0-RTT support needed a newer nginx than the one on this machine, and the module still describes itself as experimental.

References