How HTTP/3 and QUIC Actually Work: UDP, Streams, and Why 0-RTT Changes Everything
You type https://example.com and hit Enter. Your browser opens a connection, negotiates TLS, sends an HTTP request, gets a response. That's the mental model most developers carry.
It's incomplete. Since 2022, there's a real chance that connection isn't TCP at all — it's QUIC. And QUIC doesn't just swap TCP for UDP. It restructures how transport, security, and application semantics interact at the packet level.
This article explains what actually happens when your browser speaks HTTP/3, from the wire format up to the stream multiplexing that makes head-of-line blocking a thing of the past.
The Short Version: What Changed
| Layer | HTTP/2 (TCP + TLS) | HTTP/3 (QUIC) |
|---|---|---|
| Transport | TCP (reliable, ordered byte stream) | QUIC over UDP (reliable, ordered per stream) |
| Security | TLS 1.3 layered over TCP | TLS 1.3 integrated into QUIC |
| Multiplexing | HTTP/2 frames on one TCP stream | QUIC native streams (independent) |
| Handshake | TCP SYN → TLS ClientHello → ... (2–3 RTT) | QUIC Initial + TLS in 1 RTT (or 0-RTT resume) |
| Connection ID | 4-tuple (src IP, src port, dst IP, dst port) | Opaque Connection ID (survives IP/port change) |
| Head-of-line blocking | Yes — one lost packet stalls all streams | No — loss only blocks the affected stream |
The headline feature: QUIC moves reliability and ordering from the connection level to the stream level. A lost packet on stream 4 doesn't stall stream 0, 8, or 12.
The Wire Format: Packets, Frames, and Encryption Levels
QUIC doesn't send "data." It sends packets, each carrying frames. The frames belong to streams. The packets live inside UDP datagrams.
Four Encryption Levels = Four Packet Types
Every QUIC packet has an encryption level. This determines which frames it can carry:
| Packet Type | Header | Encryption Level | Frames Allowed |
|---|---|---|---|
| Initial | Long | Initial keys (derived from client's random DCID) |
CRYPTO, ACK, PADDING, PING, CONNECTION_CLOSE
|
| 0-RTT | Long | 0-RTT keys (resumed session) | Everything Initial allows + STREAM, MAX_DATA, NEW_CONNECTION_ID, DATAGRAM... |
| Handshake | Long | Handshake keys | Everything 0-RTT allows + NEW_TOKEN, PATH_RESPONSE, HANDSHAKE_DONE
|
| 1-RTT | Short | 1-RTT keys (established) | All frames — this is steady-state traffic |
The long header (used during handshake) is self-describing: version, both Connection IDs with lengths, explicit packet type. The short header (1-RTT) strips all that — just a header byte, Destination CID, packet number, and two flag bits (spin bit, key phase). Header protection encrypts the packet number and reserved bits so on-wire they look like noise.
Frames: The Actual Payload
Frames are typed. The workhorse is STREAM — carries application data with a stream ID, offset, length, and FIN bit. Others:
- ACK — acknowledges received packet numbers (with ECN support)
- CRYPTO — carries TLS handshake messages (yes, TLS lives inside QUIC frames)
- MAX_DATA / MAX_STREAM_DATA / MAX_STREAMS — flow control
- NEW_CONNECTION_ID / RETIRE_CONNECTION_ID — connection migration
- PATH_CHALLENGE / PATH_RESPONSE — path validation for migration
- DATAGRAM (RFC 9221) — unreliable, unordered data for real-time media
- CONNECTION_CLOSE — terminates connection (with error code)
Multiplexing happens at the frame level. One packet can carry a STREAM frame for stream 0, another STREAM for stream 4, and an ACK frame — all at once. There's no "this packet belongs to stream X." The frames inside decide.
Coalescing ≠ Multiplexing
Multiple QUIC packets (even from different packet number spaces) can be concatenated into one UDP datagram. This is coalescing — distinct from multiplexing. A single datagram can do both: coalesce an Initial + Handshake packet, each carrying frames from different streams.
Connection IDs: The Connection That Outlives Its Address
TCP identifies a connection by the 4-tuple. Change IP or port (Wi-Fi to cellular, NAT rebinding) and the connection dies.
QUIC uses Connection IDs (CIDs). Each endpoint chooses the CIDs it wants to receive and advertises them in the Source CID field of packets it sends. The peer copies that into the Destination CID of return packets.
Key insight: the Destination CID names the recipient, not the sender. On a client-to-server packet, the DCID is a value the server chose. The SCID is a value the client chose.
The client's very first Initial packet has no server CID yet, so it generates a random DCID (8+ bytes). This random value also seeds the Initial encryption keys. Once the server replies with its own SCID, the client adopts that as its new DCID and never uses the random one again.
Endpoints issue multiple CIDs over a connection's life (NEW_CONNECTION_ID frames). A migrating client switches to an unused CID, preventing passive observers from linking old and new paths by a constant CID.
Load balancers exploit this: they encode routing info in the CID they hand out, so later packets are steered to the right server without deep packet inspection.
The Handshake: 1-RTT by Default, 0-RTT on Resume
TCP + TLS 1.3: 2 RTT Minimum
Client Server
SYN ──────────────────→
←────────────────── SYN+ACK
ClientHello ─────────→
←──────── ServerHello, Certificate, Finished
Finished ───────────→
Application Data ────→
Two round trips before application data flows (TCP handshake + TLS handshake). TCP Fast Open and TLS early data exist but are hampered by middleboxes.
QUIC: 1 RTT (New Session)
Client Server
Initial (CH) ────────→
←──────── Initial (SH, Cert, Finished)
Handshake (Finished) →
1-RTT (App Data) ────→
The QUIC Initial packet is the ClientHello. The server's Initial response carries ServerHello, Certificate, and Finished. The client's Handshake packet carries its Finished. Application data flows on 1-RTT packets immediately after.
0-RTT: Send Data Before the Server Replies
If the client has a session ticket from a previous connection (containing server transport parameters and TLS resumption PSK), it can send 0-RTT packets with application data alongside its Initial packet.
Client Server
Initial + 0-RTT (App) →
←──────── Initial (SH, Cert, Finished)
Handshake (Finished) →
1-RTT (App Data) ────→
The catch: 0-RTT provides no replay protection. A captured 0-RTT packet can be resent by an attacker, and the server will process it again. HTTP/3 mitigates this via RFC 9221 — only idempotent requests (GET, HEAD) should use 0-RTT, and servers must implement anti-replay (single-use tokens, timestamp windows).
Streams: Independent, Ordered, Flow-Controlled
Each QUIC stream is an independent, reliable, ordered byte stream. Streams have:
- Stream ID — 62-bit integer. Client-initiated bidirectional: 0, 4, 8... Server-initiated bidirectional: 1, 5, 9... Client-initiated unidirectional: 2, 6, 10... Server-initiated unidirectional: 3, 7, 11...
- Offset — byte position within the stream (like TCP sequence number but per-stream)
- FIN bit — marks end of stream
Flow control is per-stream AND connection-wide. MAX_STREAM_DATA limits bytes on a single stream; MAX_DATA limits total bytes across all streams. MAX_STREAMS limits concurrent stream count. This prevents a runaway stream from starving others.
No Head-of-Line Blocking
TCP delivers bytes in strict sequence order. If packet N is lost, packet N+1 sits in the receive buffer until N is retransmitted — even if N+1 belongs to a completely different logical request.
QUIC delivers per-stream. If stream 4 loses a packet, stream 0, 8, and 12 keep delivering data to the application. The transport layer handles retransmission for stream 4 independently.
# Conceptual: HTTP/3 request streams are independent
# Stream 0: GET /api/user → HEADERS + DATA frames
# Stream 4: GET /api/posts → HEADERS + DATA frames
# Stream 8: GET /api/comments → HEADERS + DATA frames
# If stream 4's packet is lost:
# - Stream 0 and 8 continue processing
# - Only stream 4's application callback waits
HTTP/3 Mapping: Requests = Bidirectional Streams
HTTP/3 maps each request-response pair to a single client-initiated bidirectional QUIC stream (stream 0, 4, 8...). The request goes on the stream; the response comes back on the same stream.
Two unidirectional control streams carry connection-level frames:
- Client to Server: SETTINGS, GOAWAY, CANCEL_PUSH
- Server to Client: SETTINGS, GOAWAY, PUSH_PROMISE, MAX_PUSH_ID
QPACK replaces HPACK. HPACK assumes in-order header delivery (true for HTTP/2 on TCP). QUIC doesn't guarantee cross-stream order. QPACK uses dedicated unidirectional streams for encoder/decoder state updates, while encoded header blocks reference static state without modifying it.
Stream Types in HTTP/3
| Stream Type | Direction | Purpose |
|---|---|---|
| Request stream | Bidirectional (client-initiated) | HTTP request + response |
| Control stream (client) | Unidirectional | SETTINGS, GOAWAY, CANCEL_PUSH |
| Control stream (server) | Unidirectional | SETTINGS, GOAWAY, PUSH_PROMISE, MAX_PUSH_ID |
| Push stream | Unidirectional (server-initiated) | Server push responses |
Connection Migration: Surviving Network Changes
When your phone switches from Wi-Fi to cellular, the IP and port change. TCP dies. QUIC survives.
Mechanism: the client sends packets from the new path with a new Destination CID (one the server issued via NEW_CONNECTION_ID). The server sees the CID, recognizes the connection, and validates the new path with PATH_CHALLENGE / PATH_RESPONSE frames (1 RTT). Once validated, traffic shifts to the new path.
Only the client can actively migrate in QUIC v1. Servers can indicate a preferred address during handshake (e.g., move from a shared anycast IP to a dedicated server IP).
Congestion control resets after migration — you're on a new path with unknown characteristics. This is why migration has a performance cost.
0-RTT in Practice: The Replay Problem
0-RTT is seductive — zero-round-trip resume. But it's a loaded gun.
Attacker captures client's 0-RTT packet (POST /api/transfer {to: attacker, amount: 1000})
Attacker replays it to server
Server processes it again → money transferred twice
HTTP/3 requires servers to:
- Only accept idempotent methods in 0-RTT (GET, HEAD)
- Implement anti-replay: single-use tokens, timestamp validation, or bloom filters
- Reject 0-RTT if anti-replay state is unavailable
Developers using HTTP/3 libraries: don't assume 0-RTT is safe for mutations. Configure your client to only send safe methods in 0-RTT, or disable 0-RTT entirely for non-idempotent APIs.
Congestion Control: The Hidden Performance Variable
QUIC specifies reliability and flow control but not congestion control. Implementations choose: CUBIC, BBR, NewReno, etc.
This matters enormously. Cloudflare's QUIC uses CUBIC; their TCP uses BBR. In production benchmarks with 1% packet loss, Cloudflare's HTTP/3 lagged behind HTTP/2 because CUBIC collapses its congestion window aggressively on early loss, while BBR (rate-based) doesn't.
Google's QUIC uses BBR — their HTTP/3 matches or beats HTTP/2 under loss.
The lesson: QUIC performance doesn't equal protocol performance. It's implementation performance. When benchmarking, you're testing that specific QUIC stack's congestion control, not QUIC itself.
Real-World Performance: What the Data Shows
| Scenario | HTTP/3 vs HTTP/2 |
|---|---|
| Small payloads (<100 KB), no loss | HTTP/3 ~12% faster (1-RTT handshake advantage) |
| Large payloads (>1 MB), no loss | Roughly equivalent |
| Added loss (1%) | Mixed: Google HTTP/3 ≈ HTTP/2; Cloudflare HTTP/3 worse (CUBIC vs BBR) |
| Added latency (100 ms) | HTTP/3 better on Google; worse on Facebook (implementation quirks) |
| Page Load Time (multi-object) | HOL blocking removal has little measurable impact on Speed Index |
| Connection migration | Works but resets congestion control — brief throughput dip |
Key findings from production studies (Cloudflare, Google, Facebook, APNIC, IMC 2021):
- The 1-RTT handshake advantage dominates for short-lived connections (<1 MB)
- For long-lived transfers, congestion control implementation matters more than protocol design
- HOL blocking removal rarely moves the needle on real page loads — most pages don't have enough concurrent streams hitting loss simultaneously
- Client implementation matters: Chrome's SSL verification adds ~20ms overhead vs command-line clients
What This Means for You
If You're a Backend Developer
- Enable HTTP/3 (ALPN h3) — it's standardized, widely supported, and free latency reduction for new connections
- Configure your QUIC stack's congestion control — BBRv2 or BBRv3 outperforms CUBIC under loss
- Set MAX_STREAMS and flow control windows appropriately — defaults may limit parallelism
- Implement 0-RTT anti-replay if you enable it — or disable 0-RTT for non-GET endpoints
If You're a Frontend Developer
- Don't assume HTTP/3 is magic. The 1-RTT handshake helps first paint; HOL blocking removal helps only under loss with many concurrent resources
- Resource prioritization still matters. HTTP/3 has no priority tree (HTTP/2 priorities were dropped). Use fetchpriority and preload instead
- Connection pooling changes. QUIC connections are more expensive to create (crypto) but cheaper to maintain (no TCP state). Reuse aggressively
If You're Running Infrastructure
- UDP traffic must pass. Corporate firewalls, load balancers, and DDoS mitigation often drop or rate-limit UDP. Test end-to-end
- CID routing. If you terminate QUIC at a load balancer, encode backend routing in the CID — otherwise migration breaks
- Monitor QUIC-specific metrics: handshake failure rate, 0-RTT acceptance rate, migration frequency, CID rotation rate
The Mental Model to Keep
┌─────────────────────────────────────────────────────────────┐
│ UDP DATAGRAM │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ QUIC Packet │ │ QUIC Packet │ (coalesced) │
│ │ (Initial, long) │ │ (Handshake, long)│ │
│ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌────────▼─────────────────────▼────────────────────────┐ │
│ │ FRAMES │ │
│ │ CRYPTO(frame) ACK(frame) PADDING(frame) │ │
│ │ STREAM(id=0, off=0, data="GET /") STREAM(id=4, ...) │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
QUIC is not "TCP over UDP." It's a new transport that integrates TLS, multiplexes at the frame level, identifies connections by CID instead of 4-tuple, and pushes reliability to the stream layer.
The next time you see h3 in your browser's network tab, you'll know exactly what's happening on the wire — and why it's not just TCP with a different header.
Further Reading
- RFC 9000 — QUIC Transport Protocol (the bible)
- RFC 9001 — QUIC TLS Integration
- RFC 9114 — HTTP/3
- RFC 9221 — QUIC DATAGRAM Extension
- Cloudflare Blog: "HTTP/3 vs HTTP/2 Performance" — real deployment data
- APNIC Blog: "Dissecting the Performance of Production QUIC" — independent benchmarks
- IMC 2021: "Dissecting Performance of Production QUIC" — academic analysis with root-cause visualization
- surajkjai.github.io/quic-http3-masque-guide — worked examples, packet diagrams, field-by-field breakdowns
If this article helped you build a correct mental model of HTTP/3, share it with a colleague who still thinks "QUIC is just TCP over UDP."
Top comments (0)