TIL: HTTP/3 Is Not Always Faster Than HTTP/2

In my parental leave time, I’ve been noodling on a new HTTP client library, and as one does, I wrote benchmarks. I expected HTTP/3 to be faster, as a matter of course. I mean, that’s why you make new network protocols right? Everyone says it’s faster, the RFCs imply it should be faster, the conference talks promise it. On my local network, HTTP/3 was consistently and measurably slower, by 50-100x.

The key differentiator is that HTTP/3 replaces TCP with QUIC, a UDP-based transport. What I didn’t realize, not having written much UDP code, is that it ends up moving congestion control and reliability into userspace. In general, when I hear “userspace” networking, I tend to imagine that it would be somehow faster by not triggering as many syscalls, but in this case that turned out false. There are a few improvements over HTTP/2, such as: a single round-trip handshake instead of two or three, independent stream loss recovery eliminating TCP’s head-of-line blocking, and connection migration that survives network changes. On lossy, high-latency links these translate directly into faster transfers, but the key phrase is “on lossy, high-latency links.”

TCP has been optimized in the kernel for decades. It benefits from hardware offloading (TSO, GRO, zero-copy paths, delayed ACKs implemented where the interrupts live). QUIC runs in userspace, and the cost is not small. A group of researchers quantified this in “QUIC is not Quick Enough over Fast Internet.” On high-bandwidth links, HTTP/3 suffered data rate reductions of up to 45.2% compared to HTTP/2. The gap widened as bandwidth increased. In that research, the kernel’s UDP stack generated 231K netif_receive_skb calls for a single QUIC download versus 15K for HTTP/2, and every one of those crosses the user-kernel boundary. QUIC’s userspace ACK processing compounds the problem; TCP’s delayed ACKs are a kernel optimization that QUIC simply cannot replicate currently.

I also discovered, that at least on Mac, there isn’t a public way to send multiple UDP packets at a time. Linux has had sendmmsgfor about 10 years.

This is what my benchmarks were showing. Locally, there is no packet loss. There is no meaningful latency. There is only the raw overhead of the protocol stack, and in that environment, the modern operating systems’ long-accumulated devotions to TCP are unmatched.

So, HTTP/3 excels on mobile networks with packet loss, high-latency intercontinental connections, sites loading many concurrent resources, and applications where connection migration matters. On stable broadband, in datacenters, between colocated services, which is where I tend to operate my software, HTTP/2 over kernel TCP is comfortably ahead.

Beyond that, userspace QUIC means more CPU per connection than kernel TCP. For CPU-bound services, HTTP/3 can reduce effective capacity.

And in the real world, firewalls, NATs, and corporate proxies have been shaped by decades of TCP assumptions. UDP traffic is frequently rate-limited or blocked.

So, if you are considering adopting HTTP/3, be sure that you really need it, or otherwise consider forcing HTTP/2 usage instead.