LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

QUIC and HTTP/3 Deep Dive

quichttp3networkingtlsperformance

HTTP/2 was supposed to fix the web. It delivered multiplexed streams over a single connection, eliminating the need to open six parallel TCP connections per origin, compressing headers with HPACK, and enabling server push. By every theoretical metric it was a step forward — and it was, right up until the moment the network got lossy. The dirty secret of HTTP/2 is that by collapsing all application streams onto one TCP connection, it traded one form of head-of-line blocking for a worse one that sits deeper in the stack and is invisible to the application. QUIC, standardized as RFC 9000 in May 2021, and HTTP/3, defined in RFC 9114, exist entirely to solve this problem. Understanding what the problem actually is — and what QUIC specifically does to fix it — is the prerequisite for making informed decisions about whether, when, and how to deploy it.

As of mid-2026, HTTP/3 is no longer an experimental curiosity. About 35% of traffic measured by Cloudflare runs over QUIC, all major browsers support it, nginx has had production HTTP/3 support since 1.25.0, and Caddy enables it by default. The question is not whether to understand QUIC but whether your specific infrastructure benefits from deploying it — and that answer depends heavily on your users’ network conditions and your tolerance for the genuine operational complexity QUIC introduces.


The TCP Head-of-Line Blocking Problem

To understand why QUIC exists, you need to understand precisely where TCP breaks under HTTP/2. TCP is a reliable, ordered byte stream. Every byte delivered to the application layer arrives in the exact sequence it was sent. That ordering guarantee is implemented through sequence numbers and retransmission: if a segment is lost, the receiver buffers everything that arrives after it and holds it back from the application until the missing segment is retransmitted and received.

HTTP/2 multiplexes multiple request/response exchanges — called streams — over a single TCP connection. From the HTTP/2 layer’s perspective, streams are independent: stream 3’s response has nothing to do with stream 7’s response. But from TCP’s perspective, there is only one byte stream, and TCP does not know or care about stream boundaries. When a single TCP segment is lost, TCP stalls delivery of everything in the receive buffer that follows the gap. That means all active HTTP/2 streams freeze while TCP handles the retransmission. On a connection with 10 active streams, one lost packet blocks all 10, even though only one stream’s data was actually in that packet.

HTTP/1.1 with six parallel connections was, paradoxically, more resilient to this: a loss event on one connection did not affect the other five. HTTP/2’s consolidation made the application-layer picture cleaner but made the transport-layer failure mode more severe.

HTTP/1.1 (6 connections)          HTTP/2 (1 connection)        HTTP/3 / QUIC
--------------------------         ---------------------         --------------
TCP conn 1: stream A           TCP: byte stream [A,B,C,D]   QUIC stream A (independent)
TCP conn 2: stream B                                          QUIC stream B (independent)
TCP conn 3: stream C           Loss of segment carrying B    QUIC stream C (independent)
TCP conn 4: stream D           => A, C, D all stall too       QUIC stream D (independent)
TCP conn 5: stream E
TCP conn 6: stream F           (All streams blocked by        Loss in stream B =>
                                one missing segment)          only stream B stalls

QUIC solves this at the transport layer by making streams a first-class concept. A QUIC connection carries multiple independent streams, and each stream has its own reliability, ordering, and flow-control state. A lost UDP packet carrying data for stream B causes stream B to wait for retransmission. Streams A, C, and D are unaffected — their data continues to be delivered to the application immediately. The head-of-line blocking is eliminated not by clever framing tricks at the application layer but by moving the abstraction boundary: QUIC speaks streams natively, so the transport layer can be selective about what it retransmits and what it continues to deliver.


What QUIC Actually Is

QUIC is a transport protocol implemented in userspace, running over UDP. It re-implements in user space most of what TCP provides in the kernel: reliable delivery, ordered byte streams (per stream), flow control (per stream and per connection), congestion control, and connection establishment. It adds features TCP does not have: built-in encryption, stream multiplexing with no HOL blocking, and connection migration via Connection IDs.

The choice of UDP as the substrate is pragmatic, not principled. UDP is already supported everywhere — every OS, every NAT, every firewall has a UDP implementation. Building QUIC on top of UDP means it can be deployed and iterated on without kernel changes. This is the key advantage of userspace: the QUIC stack in your application process can be updated, patched, and tuned independently of the OS networking stack. Comparing the velocity of QUIC congestion control algorithm improvements against kernel TCP changes makes the benefit concrete — shipping a better CUBIC variant or experimenting with BBRv3 in a QUIC library is a library release, not a kernel patch cycle.

The Protocol Stack

HTTP/1.1 and HTTP/2 stack:        HTTP/3 stack:
--------------------------         ---------------
  HTTP/1.1 or HTTP/2               HTTP/3
  TLS 1.2 or TLS 1.3               QUIC (streams, crypto, flow ctrl)
  TCP                              UDP
  IP                               IP

Every QUIC connection is encrypted. There is no cleartext QUIC — encryption is not a negotiated option but a structural requirement. The TLS 1.3 cryptographic handshake is integrated into the QUIC transport handshake; you cannot separate them. This has operational implications: passive inspection of QUIC traffic at the packet level reveals only packet headers (which are also partially encrypted in QUIC version 1), not HTTP request content, not stream metadata, and not any information about individual HTTP/3 requests.


The Integrated Handshake and 0-RTT

A conventional HTTPS connection over TCP involves a TCP three-way handshake (1 RTT), then a TLS 1.3 handshake (1 RTT), for a total of 2 RTTs before the first byte of HTTP data can be sent. TLS 1.2 added another RTT. QUIC collapses this. The QUIC Initial and Handshake packets carry the TLS ClientHello and ServerHello inline, so the transport handshake and the cryptographic handshake happen simultaneously. A new QUIC connection requires 1 RTT before the client can send HTTP/3 requests.

If the client has connected to this server before and has a session ticket (the QUIC equivalent of a TLS session resumption token), QUIC supports 0-RTT data: the client can include application data in the very first QUIC packet, before receiving any acknowledgment from the server. For a page load, this can eliminate the handshake latency entirely for returning users.

TCP + TLS 1.3 (new connection):    QUIC (new connection):
---------------------------        ----------------------
 Client --[SYN]---------> Server    Client --[Initial + ClientHello]--> Server
 Client <--[SYN-ACK]----- Server    Client <--[Initial + ServerHello]-- Server
 Client --[ACK]---------> Server    Client --[Handshake + Finished]---> Server
 Client --[ClientHello]-> Server    Client --[1-RTT data]-------------> Server
 Client <--[ServerHello]- Server
 Client --[Finished]----> Server   (1 RTT before data)
 Client --[HTTP data]---> Server

(2 RTTs before data)

QUIC 0-RTT (resumption):
--------------------------
 Client --[Initial + 0-RTT data]--> Server
                                    (data delivered immediately)

The 0-RTT caveat is replay attacks. Data sent in 0-RTT packets is encrypted with a key derived from the previous session’s ticket, not from a fresh exchange. An attacker who captures those first packets can replay them to the server — the server cannot distinguish a replayed 0-RTT packet from a genuine retransmit. This means 0-RTT data must only be used for idempotent requests. Safe uses: GET requests for cacheable resources. Unsafe uses: POST requests that create or modify state, payment submissions, any mutation. Backends that use 0-RTT must consume and honor the Early-Data request header and respond with 425 Too Early for non-idempotent operations. This is not theoretical: production deployments that blindly enable 0-RTT on APIs have introduced replay-exploitable mutations. For deployments where 0-RTT replay risk outweighs the latency benefit, it can be disabled in the QUIC stack configuration. For more on the TLS 1.3 foundations that QUIC builds on, see how HTTPS and TLS work.


Connection Migration

Every TCP connection is identified by a four-tuple: source IP, source port, destination IP, destination port. If any element changes, the connection is gone. A mobile user switching from Wi-Fi (192.168.1.5) to cellular (10.20.30.40) gets a new source IP, which means all TCP connections must be torn down and re-established. Every in-flight HTTP/2 stream is lost. Every TLS session is lost. The next page load or API call starts from scratch.

QUIC connections are identified by a Connection ID — an opaque, server-chosen value carried in QUIC packet headers. The Connection ID has no relationship to IP addresses or ports. When a client’s IP changes, the client sends QUIC packets with a new source address but the same (or newly migrated) Connection ID. The server recognizes the Connection ID, validates the path, and the connection continues without renegotiation. HTTP/3 streams survive the network transition.

Connection migration has infrastructure implications. For single-server deployments, migration works immediately. For load-balanced deployments behind multiple backends, the Connection ID must carry routing information so that post-migration packets reach the same backend. IETF RFC 9386 (QUIC Load Balancer) defines a structured encoding for embedding backend routing tokens in Connection IDs. For anycast deployments — where multiple edge nodes share one IP and routing is BGP-based — a migrated connection might land on a different anycast node with no session state. Implementations that care about migration correctness at scale must either encode routing hints in Connection IDs or use consistent-hash routing by Connection ID at the load balancer layer. See the HAProxy deep dive for load-balancing fundamentals that apply here.


QPACK and HTTP/3 Stream Mapping

HTTP/2 uses HPACK for header compression. HPACK maintains a dynamic table of recently seen header name/value pairs on both the encoder and decoder sides; compressed headers are references into these tables. HPACK works correctly only because HTTP/2 guarantees that HEADERS frames are processed in order — the receiver’s dynamic table must be updated in the same sequence as the sender’s, or references become invalid.

HTTP/3 cannot use HPACK directly. On QUIC, streams are delivered independently, which means HEADERS frames for stream 5 might arrive before stream 3’s headers, in a different order than they were sent. HPACK’s mandatory ordering would reintroduce HOL blocking at the header-compression level: a missing HEADERS frame would stall all subsequent header decoding until it arrived.

QPACK (RFC 9204) replaces HPACK in HTTP/3. QPACK uses two dedicated unidirectional streams per connection: an encoder stream (server to client) and a decoder stream (client to server). Dynamic table updates flow over these streams. QPACK is designed so that individual request/response header blocks can be compressed without blocking on dynamic table entries if needed — implementations can choose to use only the static table (56 predefined header name/value entries) for headers on streams that must not be blocked. Headers that reference dynamic table entries that have not yet been received are buffered, but this blocking is localized to that stream, not to all streams. In practice, QPACK achieves compression ratios close to HPACK on stable connections while preserving QUIC’s stream independence guarantee.

The HTTP/3 stream model maps cleanly onto QUIC streams. Each HTTP request/response pair occupies one bidirectional QUIC stream. HTTP/3 also uses three types of unidirectional streams: the QPACK encoder stream, the QPACK decoder stream, and a control stream for HTTP/3 settings frames (analogous to the HTTP/2 SETTINGS frame but using a dedicated stream instead of a reserved stream ID).


The Operational Reality

UDP Blocking and Fallback

QUIC runs on UDP, and UDP/443 is not universally reachable. Enterprise firewalls, some carrier-grade NAT implementations, and certain ISPs either block or heavily rate-limit UDP traffic that is not DNS or NTP. Research in 2025 found that roughly 3-5% of clients globally cannot reach QUIC endpoints, either because UDP/443 is filtered or because stateful middleboxes time out QUIC connections that look idle (QUIC keepalive intervals and UDP state timeouts in NAT devices often do not align well).

The solution is Alt-Svc with graceful fallback. A server running HTTP/3 advertises this over HTTP/2 via the Alt-Svc response header. The client receives an HTTP/2 response and then attempts a QUIC connection in parallel (using the “Happy Eyeballs” algorithm variant for protocols). If the QUIC attempt fails or times out, the client continues using the existing HTTP/2 connection. This means HTTP/3 is always an upgrade from HTTP/2, never a hard requirement, and a QUIC outage does not take down your service.

Increasingly, the HTTPS DNS record (RFC 9460 SVCB/HTTPS) is used as the discovery mechanism instead of Alt-Svc. An HTTPS record with alpn=h3 allows browsers to attempt QUIC on the first connection rather than discovering it on a second request. Safari has used HTTPS DNS records as the primary HTTP/3 discovery path for several releases.

CPU Cost

QUIC in userspace is more CPU-intensive than kernel TCP. Every QUIC packet requires at least one syscall (a UDP sendmsg), whereas kernel TCP can batch segments internally. QUIC’s per-packet encryption (AEAD for every packet, plus header protection) adds cryptographic overhead that kernel TLS offload (kTLS) partially alleviates for TCP but does not apply to QUIC.

The gap has narrowed considerably. Linux UDP GSO (Generic Segmentation Offload) and GRO (Generic Receive Offload) allow QUIC implementations to batch multiple UDP datagrams into a single syscall, reducing the syscall overhead significantly. Modern QUIC libraries (quic-go, quiche, lsquic, MsQuic) implement GSO/GRO where available. Hardware NICs with UDP segmentation offload help further. The practical cost depends heavily on packet rates: for low-volume services, the overhead is negligible; for high-throughput CDN edge nodes, QUIC CPU cost is a real engineering concern that has driven NIC offload work.

Observability and Debugging

QUIC is the hardest transport protocol to debug passively. TCP with TLS still exposes TCP-level metadata to packet capture: sequence numbers, ACKs, retransmission events, and window sizes are visible to tcpdump and Wireshark without decryption. QUIC encrypts its packet headers (beyond the packet number), so a passive observer cannot distinguish data packets from ACKs, cannot measure retransmissions directly, and cannot correlate packet captures to individual HTTP/3 streams without the session keys.

Active debugging requires two tools: SSLKEYLOGFILE and qlog. Setting SSLKEYLOGFILE=/path/to/keys.log in the environment of a QUIC client or server (where the QUIC library honors it — quic-go, Chromium, and curl all do) dumps the TLS session keys in NSS key log format. Wireshark can load this file and decrypt QUIC packets in a packet capture, revealing stream data, QPACK-decoded headers, and frame-level detail.

qlog (RFC 9473) is the structured logging format defined specifically for QUIC. A QUIC endpoint emitting qlog produces a JSON (or CBOR) trace of every packet sent and received, with timing, packet numbers, stream offsets, and congestion control state. The qvis tool at qvis.quic.tools renders qlog files as waterfall diagrams, sequence charts, and congestion window plots. In production, emitting qlog for all connections is expensive; sampling a small percentage (1-5%) of connections to qlog and sending to a logging backend is the practical pattern. For the nftables and firewall layer that UDP traverses, the Linux networking post covers the tooling.


Deployment

nginx

nginx has supported HTTP/3 since version 1.25.0 (May 2023). The build requires either BoringSSL or OpenSSL 3.2+ (OpenSSL 3.5.1 is the recommended version as of 2026). Mainstream Linux distribution packages for nginx do not always include QUIC support; verify with nginx -V | grep quic or use the official nginx mainline packages or a custom build.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
server {
    # QUIC/HTTP/3 on UDP 443
    listen 443 quic reuseport;
    # TCP/HTTP/2 and HTTP/1.1 on TCP 443 (fallback)
    listen 443 ssl;

    server_name example.com;

    ssl_certificate     /etc/nginx/ssl/example.com.crt;
    ssl_certificate_key /etc/nginx/ssl/example.com.key;

    # QUIC requires TLS 1.3
    ssl_protocols TLSv1.3;

    # Enable HTTP/3
    http3 on;

    # Address validation (recommended for anti-amplification)
    quic_retry on;

    # Stable token key across reloads
    quic_host_key /etc/nginx/quic_host.key;

    # Advertise HTTP/3 to clients over HTTP/2 connections
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    location / {
        proxy_pass http://backend;
    }
}

The reuseport parameter on the UDP listen directive is required for multiple worker processes to share the same UDP socket correctly. Without it, only one worker receives QUIC packets, eliminating parallelism. The quic_retry on directive enables a stateless retry mechanism that validates the client’s source address before completing the handshake, providing protection against source-spoofed amplification attacks at the cost of one additional RTT for new connections.

Verify that HTTP/3 is being advertised and negotiated:

1
2
3
4
5
6
7
8
9
# Check Alt-Svc header on HTTP/2 response
curl -sI --http2 https://example.com | grep -i alt-svc

# Request directly over HTTP/3
curl --http3 -sI https://example.com

# Confirm the protocol used
curl -w '%{http_version}\n' -o /dev/null -s https://example.com
# Should print "3" for an HTTP/3 connection

For firewall configuration, UDP/443 must be open inbound. If you are running nftables, an explicit rule accepting UDP/443 is required — it is not automatically covered by an existing TCP/443 accept rule.

Caddy

Caddy requires no HTTP/3 configuration. Since version 2.6, HTTP/3 is on by default whenever HTTPS is configured. A minimal Caddyfile:

1
2
3
example.com {
    reverse_proxy localhost:8080
}

This serves HTTP/3 on UDP/443, HTTP/2 and HTTP/1.1 on TCP/443, handles certificate provisioning via ACME, and emits the Alt-Svc header automatically. For most homelab and small-production deployments, Caddy is the lowest-friction path to HTTP/3. See the Caddy homelab reverse proxy guide for the broader Caddy deployment picture.

Cloudflare

Cloudflare enables HTTP/3 with one toggle in the Speed > Optimization settings. Cloudflare terminates QUIC at the edge and proxies to your origin over whatever protocol your origin supports — HTTP/2, HTTP/1.1, or HTTP/3 if you have configured your origin for it. For most configurations, Cloudflare handles the QUIC complexity on the client-facing side while the origin-facing connection remains TCP. This is operationally the simplest HTTP/3 deployment: no nginx rebuild, no UDP firewall rules at the origin, and Cloudflare’s global anycast network handles the connection migration and load-balancing complexity. The tradeoff is that you are measuring Cloudflare’s QUIC performance, not your own, and your observability into QUIC sessions is limited to what Cloudflare exposes in Analytics. See Cloudflare Workers and the edge for more on Cloudflare’s architecture.


HTTP/2 vs HTTP/3: Direct Comparison

Property HTTP/2 HTTP/3
Transport TCP QUIC (over UDP)
HOL blocking Yes — single TCP stream blocks all app streams No — each QUIC stream is independent
New connection RTTs 2 (TCP + TLS 1.3) 1 (QUIC integrated handshake)
Resumption TLS session tickets (1 RTT) 0-RTT (0 RTTs, with replay caveat)
Encryption TLS separate from transport Mandatory, integrated into QUIC
Connection identity 4-tuple (IP + port) Connection ID
Connection migration No — IP change kills connection Yes — survives IP change
Header compression HPACK QPACK
Congestion control Kernel TCP (CUBIC/BBR) Userspace per connection (any algorithm)
Packet capture TCP metadata visible Encrypted headers; needs key log file
UDP blocking risk None 3-5% of networks

When You Actually Need It

QUIC’s benefits are not uniformly distributed. On clean, low-latency datacenter links between servers you control — a microservice talking to another microservice over a 10GbE LAN — HTTP/2 over TCP performs equally well and is far easier to observe and debug. TCP packet loss on a datacenter fabric is measured in fractions of a percent, and congestion events are rare. The HOL-blocking problem that QUIC solves is negligible in this environment.

QUIC’s advantages become meaningful in three scenarios. First, mobile and cellular networks: packet loss rates of 1-5% are common on 4G, higher on marginal coverage; this is exactly the environment where per-stream independence matters. Second, global user bases with high-latency paths: the 1-RTT handshake (vs. 2-RTT for TCP+TLS 1.3) shaves measurable latency on 150ms+ RTT paths; 0-RTT resumption can eliminate it for returning users. Third, connection migration: users on mobile devices who frequently switch networks see real benefit from connections surviving the IP change rather than requiring full re-establishment.

For origin-to-CDN and CDN-to-client traffic, Cloudflare’s data showing 30-35% of traffic over QUIC reflects the web’s aggregate: a large fraction of users are on mobile or high-latency paths, and CDN deployments like Cloudflare’s have the operational maturity to absorb QUIC’s complexity. For a stationary user on a wired connection with 5ms RTT to your server, the measurable difference between HTTP/2 and HTTP/3 is small. gRPC, which runs over HTTP/2 today and has emerging HTTP/3 transport support in some implementations, is another space where stream multiplexing characteristics matter — but that tradeoff analysis belongs in a gRPC-specific discussion.


Verdict

QUIC and HTTP/3 are real infrastructure, not future technology. The protocol is standardized, the implementations are mature, and the tooling to deploy, debug, and operate it exists. The problem it solves — TCP HOL blocking under HTTP/2 — is a genuine architectural flaw that affects real users on real networks, not a benchmark artifact.

The deployment recommendation breaks cleanly by use case. If you run a public-facing service with a significant mobile or geographically distributed user base and you already have nginx mainline or are running Caddy, enable HTTP/3 now. The fallback behavior is solid, and the risk of deployment is low. If you are running behind Cloudflare, turn on the toggle; Cloudflare handles the hard parts. If you are building internal service-to-service infrastructure on reliable datacenter networks, HTTP/2 over TCP is the right call — QUIC adds complexity without meaningful performance benefit in that environment, and the observability loss is a real cost.

The areas that still warrant caution are 0-RTT (disable it for non-idempotent operations, full stop), load balancing at scale (you need Connection ID-aware routing if you care about migration), and debugging infrastructure (invest in SSLKEYLOGFILE + qlog pipelines before you need them, not after an incident). QUIC’s userspace nature means the ecosystem will keep improving — congestion control, GSO offload, and observability tooling are all active areas. The protocol is worth understanding deeply now, because it will become the default transport for user-facing HTTP traffic over the next few years whether you actively adopt it or not.


Sources

Comments