Dev.to · 9 min read

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

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 (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 (

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Cybersecurity News