
🌐 Also available in: Deutsch · Français · Español
Many users of my Nextcloud instances are on their phones. That is exactly what HTTP/3 was built for: it runs over QUIC instead of TCP, needs one round trip less to set up a connection, copes better with packet loss, and can keep a connection alive when the phone switches from Wi-Fi to mobile data. My goal, however, was not “have HTTP/3” but: HTTP/3 only if it measurably helps – more throughput or a more stable connection on a bad network.
This post describes how I switched it on, how I prove that it really runs – and the two brakes that made HTTP/3 clearly slower than HTTP/2 at first.
🔌 The controller can’t – its nginx can
My ingress is F5’s NGINX Ingress Controller. It has no option for HTTP/3: no switch, no ConfigMap key, nothing in the release notes. A look inside the running pod, though, shows that the bundled nginx 1.31.7 is built with --with-http_v3_module, together with OpenSSL 3.5, the first OpenSSL release that does server-side QUIC. And because the controller runs with hostNetwork, nginx can open UDP 443 directly.
So it goes through the snippets I already use:
# server-snippets of the main site (once per address):
listen 443 quic reuseport rcvbuf=4m sndbuf=4m;
listen [::]:443 quic reuseport rcvbuf=4m sndbuf=4m;
# http-snippets of the controller:
map $scheme $http3_alt_svc {
https 'h3=":443"; ma=604800';
default "";
}
http3_stream_buffer_size 1m;
quic_gso on;
# next to the other security headers (location-snippets):
add_header Alt-Svc $http3_alt_svc always;reuseportonly once per address: nginx allows this option in a singlelistenline per address and port. So it sits only in the main site’s server block, and every other host shares those sockets.- Alt-Svc over HTTPS only: this header tells browsers that HTTP/3 exists. The
mapsets it for HTTPS only; on port 80 the value stays empty, and nginx then sends no header at all. - Alt-Svc next to the other headers: nginx does not inherit
add_headeronce a location sets its own headers. My security headers live in the location snippets – so Alt-Svc has to go in there too. - Firewall: UDP 443 inbound for IPv4 and IPv6. Outbound needs no rule; the replies are “established” for conntrack.
Everything hangs on one switch per host (ingress_http3_enabled), off by default. I switched on the blog first, then the smaller Nextcloud. Before the real run I checked the new lines with nginx -t against a copy of the configuration inside the pod.
The takeaway: “not supported” sometimes only means “has no switch”. nginx -V shows what the binary can really do.
🤔 Why I thought HTTP/3 was impossible for so long
I had checked this once before, in August – with the result “not possible”. That is also what a comment in my firewall template said ever since. Back then I checked two things: does the controller have an option for HTTP/3? No. Does the server answer over HTTP/3? No, of course not – there was no QUIC listener.
What I had not checked: whether the controller’s nginx contains the module at all. The conclusion “no option, therefore no support” was too quick. Whether the image back then already had the module cannot be traced today – the current one has it, together with an OpenSSL version that does server-side QUIC. Only the question “what can the binary do?” instead of “what does the configuration offer?” led to the other answer.
The takeaway: a “not possible” deserves a date and a reason – and a second check once the software has changed.
✅ How to see that HTTP/3 really runs
A page that loads says nothing about the protocol. I ran these checks after every switch:
curl -sI --http2 https://www.apt-upgrade.me/ | grep -i alt-svc
alt-svc: h3=":443"; ma=604800
curl -s --http3-only -o /dev/null -w '%{http_code} h%{http_version}\n' https://www.apt-upgrade.me/
200 h3- Forced HTTP/3 with
curl --http3-onlyon every important path: front page, login, Nextcloud’sstatus.php, the OCS API, WebDAV, Collabora – the expected answer over h3 everywhere. - Same content: a large JavaScript file loaded over HTTP/2 and HTTP/3 – identical SHA-256 checksum. The response headers are the same, too.
- Server side:
ss -ulnpshows one UDP socket per nginx worker on port 443, IPv4 and IPv6; the access log shows requests withHTTP/3.0. - From outside: http3check.net confirms QUIC version 1 and HTTP/3, tested over IPv6 – which I cannot test from home at all.
- Security unchanged: the fail2ban filters match login lines with
HTTP/3.0just likeHTTP/2.0. 0-RTT early data stays off, so nobody can replay a login form.
One small trap: my first fail2ban test failed – with a hand-made log line in the wrong format. With a real line from the log, with only the protocol replaced, the filter matched at once.
The takeaway: proof needs several angles – client, server, a third party from outside, and your own security tools.
🐢 Brake 1: 64 KB per round trip
Small requests were as fast or faster over HTTP/3 right away. Then came the test with a large file: 5.7 MB took 0.18 seconds over HTTP/2 and 0.99 seconds over HTTP/3 – more than five times as long.
The client barely used any CPU; it was waiting. The arithmetic led to the cause: by default nginx buffers only 64 KB per QUIC stream (http3_stream_buffer_size). A stream cannot have more in flight. 64 KB per round trip at 11 ms is exactly the measured 5.8 MB/s. On a mobile link with 50 ms it would be about 1.3 MB/s – so HTTP/3 would have slowed down my users on the go.
With a 1 MB buffer, downloads over HTTP/3 ran at 27–31 MB/s, on par with HTTP/2.
The takeaway: a new protocol brings new defaults. A single test with a large file was enough to find the brake – which is why it belongs in every acceptance test.
🧺 Brake 2: a socket buffer that overflows
Then the other direction: a 20 MB upload. HTTP/3 averaged 7.8 MB/s, HTTP/2 11.9 MB/s – and during that time the server counted 284 dropped UDP packets (RcvbufErrors in /proc/net/snmp).
The reason lies in the difference to TCP. With TCP the kernel handles flow and buffers itself. QUIC, by contrast, runs in the application, over UDP: every packet waits in the socket’s receive buffer until nginx picks it up. By default Linux gives a socket at most 208 KB – a fast upload fills that, and the kernel throws further packets away. QUIC has to resend them and slows itself down.
The fix has two parts:
- Raise the kernel limit:
net.core.rmem_maxandnet.core.wmem_maxto 7.5 MB – the value QUIC implementations recommend. This only raises the ceiling; every other socket keeps its default. - Request the buffers: the QUIC listeners ask for larger buffers themselves with
rcvbuf=4m sndbuf=4m. The kernel doubles the value internally;ss -mthen shows 8 MB per socket.
Afterwards: 0 dropped packets, and uploads over HTTP/3 were on the level of HTTP/2 – the remaining variation came from my own uplink.
The takeaway: if you run UDP at high throughput, you need to know your socket buffers. The kernel counts the dropped packets – you only have to look.
📊 The measurements
I measured from my homelab with curl, 15 fresh connections per value (median). The blog is further away (about 42 ms per round trip), the Nextcloud closer (about 11 ms).
| Measurement | HTTP/2 | HTTP/3 |
|---|---|---|
| Blog: connection + encryption ready | 96 ms | 58 ms |
| Blog: first byte of the front page | 139 ms | 104 ms |
| Blog: complete front page | 268 ms | 227 ms |
| Nextcloud: connection + encryption ready | 31 ms | 27 ms |
| Nextcloud: 5.7 MB download, default buffer | 34 MB/s | 5.8 MB/s |
| Nextcloud: 5.7 MB download, 1 MB stream buffer | 28 MB/s | 30 MB/s |
| Nextcloud: 20 MB upload, 208 KB socket buffer | 11.9 MB/s | 7.8 MB/s |
| Nextcloud: 20 MB upload, 8 MB socket buffer | 15.1 MB/s | 13.0 MB/s |
| Nextcloud: 100 MB upload, 8 MB socket buffer | 8.7 MB/s | 7.7 MB/s |
| Collabora: discovery request (47 KB) | 55–72 ms | 57 ms |
At 100 MB my own uplink limits both protocols equally, with no dropped packets. Larger downloads could not be tested without a user account: the largest publicly reachable file on the Nextcloud is the 5.7 MB script file. Collabora edits documents over WebSockets, which keep running over TCP – the only thing to measure there is the discovery request.
The gain in connection setup is one round trip: TCP and TLS need two, QUIC does both in one. For the blog at 42 ms that is about 40 ms per new connection; for the nearby Nextcloud almost nothing. On mobile networks with 50 to 100 ms the gain is correspondingly larger.
The takeaway: HTTP/3 mostly saves waiting time, not bandwidth. The further away the user, the more it helps.
🔧 The remaining knobs
quic_gso on: nginx hands the kernel several QUIC packets at once instead of one per call. That saves CPU when sending; my virtual network cards do it in software, which still helps. Switched on.- Alt-Svc for 7 days instead of 1: returning visitors start on HTTP/3 directly for longer. If HTTP/3 is switched off later, each browser loses at most one attempt – it falls back to HTTP/2 on its own. Set.
quic_bpf: makes sure that packets of a connection reach the right nginx worker after a network switch. For that, nginx has to load an eBPF program, which needs extra privileges (CAP_BPF,CAP_NET_ADMIN) – in exactly the container that faces the internet and deliberately holds a single capability. Not switched on: if a migration fails, the phone simply reconnects, with QUIC in one round trip.- 0-RTT (
ssl_early_data): would save another round trip on returning connections, but such requests can be replayed – logins included. Stays off.
The takeaway: every knob has a price. What costs little goes on; what weakens the hardening stays off, even if it looks faster on paper.
🧭 From the first visit: the HTTPS record in DNS
Alt-Svc has one flaw: a browser only learns about HTTP/3 from the first response. The very first visit therefore always runs over HTTP/2. A dedicated DNS record of type HTTPS (RFC 9460) fixes that: it tells the browser during name resolution which protocols the server speaks.
# DNS zone (example): HTTPS record, priority 1, "." = same name
www.example.org. 3600 IN HTTPS 1 . alpn="h3,h2"
# check it:
dig +short HTTPS www.example.org
1 . alpn="h3,h2"The 1 is the priority, the dot means “the same name”, and alpn lists the protocols in the preferred order. I deliberately leave out IP hints – they would have to be maintained with every address change. Chrome, Firefox and Safari use the record. Not every DNS management interface offers the type, so it is worth checking before wondering why it is missing.
That is exactly my case: the DNS management at Strato, where apt-upgrade.me lives, offers only A, AAAA, CNAME, MX, NS, TXT and SRV records – I cannot set an HTTPS record there. It cannot be emulated either; browsers only use the real type. So the blog stays with Alt-Svc: the first visit runs over HTTP/2, every further one goes straight to HTTP/3 for seven days. Switching the DNS provider just for this would not be worth it to me.
The takeaway: the server can only offer HTTP/3. Whether the browser uses it on the very first visit is up to DNS.
📱 What it brings my users – and what it doesn’t
- Browsers on phones: the biggest gain – faster setup, no stall of all requests when one packet is lost, and the connection survives the switch between Wi-Fi and mobile data.
- Nextcloud app on iPhone: iOS uses HTTP/3 on its own as soon as the server announces it via Alt-Svc.
- Android app and desktop client: as far as I know they use network libraries without HTTP/3. The access log will show the real share after a few days.
- Collabora and notify_push: run over WebSockets. nginx cannot do WebSockets over HTTP/3, so these connections stay on HTTP/1.1 over TCP – that works, just without the benefit.
What I could not measure from here is the behaviour on a bad network. That is the next test: with a phone in an area with weak reception, HTTP/3 on and off in comparison.
The takeaway: be honest about who benefits. A protocol upgrade only helps the clients that speak it.
📅 Status and outlook
HTTP/3 now runs on all three servers: the blog and both Nextcloud instances. I accepted the second Nextcloud with the same checks as the first, and the drift check reports changed=0 everywhere – the servers match the code in the public repository exactly.
In a week I will look at three things:
- Usage: what share of the requests in the access log comes in over
HTTP/3.0– split by browsers, Nextcloud apps and sync clients? - Errors: are there more error responses or aborted uploads over HTTP/3 than over HTTP/2?
- Practice: how does the Nextcloud behave on a phone with weak reception, with and without HTTP/3?
I will publish the numbers within the next seven days in a separate post. If HTTP/3 brings my users little or shows more errors, I switch it off again per server – one switch and one playbook run.
🎯 The lessons
- First check what the binary can do – “no option” does not mean “no module”.
- Test with large files, in both directions – small requests hide every throughput brake.
- UDP buffers are part of QUIC –
RcvbufErrorsshow whether the kernel is dropping packets. - Test with real log lines – a hand-made example only tests the example.
- Switch it on because it helps – measured, not assumed, and with a switch that can be flipped back.





