DEV Community

Cover image for How HTTP/3 and QUIC Actually Work: UDP, Streams, and Why 0-RTT Changes Everything
Syed Anzar
Syed Anzar

Posted on

How HTTP/3 and QUIC Actually Work: UDP, Streams, and Why 0-RTT Changes Everything

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 ────→
Enter fullscreen mode Exit fullscreen mode

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) ────→
Enter fullscreen mode Exit fullscreen mode

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) ────→
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

HTTP/3 requires servers to:

  1. Only accept idempotent methods in 0-RTT (GET, HEAD)
  2. Implement anti-replay: single-use tokens, timestamp validation, or bloom filters
  3. 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, ...)   │  │
│  └────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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)