How QUIC Works: Streams, Connection IDs, and Why HTTP/3 Left TCP
TCP names a connection by four numbers: two addresses and two ports. Change any of them and the connection is gone. It also delivers one ordered byte stream. Lose a packet and every later byte waits, including bytes that belong to a different HTTP request sharing the same socket.
QUIC was designed to keep reliability and congestion control while dropping those two constraints. It runs over UDP, identifies a connection by an explicit Connection ID, multiplexes independent streams, and folds TLS 1.3 into the first flight of packets. HTTP/3 is the application mapping that uses those streams. This article is about the transport, not a second tour of TCP handshakes or UDP checksums.
The contract
RFC 9000 defines QUIC as a UDP-based multiplexed and secure transport. An endpoint that speaks QUIC offers:
- Authenticated, encrypted packets after the handshake (RFC 9001 requires TLS 1.3).
- Multiple streams on one connection. Each stream is ordered and reliable. Streams do not share a single receive sequence.
- Connection identity that can survive a change of IP address or UDP port.
- Flow control at both stream and connection scope, plus congestion control.
What it does not offer is a kernel-visible TCP socket. User-space libraries implement the state machine. The operating system sees datagrams on a UDP port, commonly 443.
Why TCP's model broke HTTP/2
HTTP/2 multiplexes request/response streams over one TCP connection. That removed the six-connection habit of HTTP/1.1. It inherited TCP's in-order delivery. One lost packet holds the TCP receive buffer. Every HTTP/2 stream behind that hole stalls until the retransmission arrives. That stall is transport head-of-line blocking. It is not the same as HTTP/1.1's one-response-at-a-time rule, but it shows up the same way in a browser: fonts wait on CSS that is waiting on a lost packet.
QUIC keeps multiplexing and moves reliability onto each stream. A STREAM frame carries a stream ID and an offset. Loss of frames for stream 4 does not prevent delivery of contiguous bytes on stream 8. Packet numbers still detect loss for congestion control; they are not a single application-visible sequence.
Packets, frames, and packet numbers
A QUIC packet rides in a UDP datagram. One datagram may hold more than one QUIC packet. A packet holds one or more frames. Common frame types:
- CRYPTO — TLS handshake bytes.
- STREAM — application data for one stream, with offset and optional FIN.
- ACK — ranges of packet numbers received.
- MAX_DATA / MAX_STREAM_DATA — connection- and stream-level flow control windows.
- NEW_CONNECTION_ID / RETIRE_CONNECTION_ID — rotate identifiers used on the wire.
- PATH_CHALLENGE / PATH_RESPONSE — prove a new path is bidirectional before migrating.
- CONNECTION_CLOSE — tear the connection down with a code.
Packet numbers increase and never reuse a number under the same keys. Retransmitted STREAM data goes in a new packet with a new number. That removes TCP's retransmission ambiguity: an ACK names a unique packet, so RTT samples stay clean.
Long headers appear during the handshake (Initial, Handshake, 0-RTT, Retry). Short headers appear once 1-RTT keys exist. Header protection hides the packet number and some flags so observers cannot trivially classify packets by number.
Connection IDs, not 4-tuples
Each endpoint chooses Connection IDs that its peer will put in the Destination Connection ID field of packets sent toward it. The ID is opaque. Length can vary; zero-length IDs exist on some paths that do not need routing hints.
Because identity lives in the QUIC header, a phone that leaves Wi-Fi and joins cellular can keep the same logical connection. RFC 9000 allows client-initiated migration in version 1. The client sends packets from the new 4-tuple using a Connection ID the server already issued. The server validates the new path with PATH_CHALLENGE before it commits congestion-controller state to that path. NAT rebinding — a mapping that changes the apparent source port — is the same problem with a smaller trigger.
Endpoints issue several Connection IDs so a path change can also change the ID. Reusing one ID across networks would let an observer link the sessions. NEW_CONNECTION_ID carries a stateless reset token used if the peer loses all state and must force the other side to drop the connection.
Handshake: one round trip, encryption first
TCP then TLS is two sequential dances. QUIC carries TLS 1.3 messages in CRYPTO frames from the first packet. A typical first-time handshake:
- Client sends an Initial packet. It contains a ClientHello and QUIC transport parameters (flow-control limits, max streams, preferred Connection IDs, and similar).
- Server replies with Initial and Handshake packets: ServerHello, certificates, Finished, and its transport parameters. It can include 1-RTT application data in the same flight.
- Client finishes TLS, derives 1-RTT keys, and may send application data.
Absent loss, the client can send request bytes after one round trip. The server can send response bytes as soon as it has processed the ClientHello and produced keys.
0-RTT lets a returning client send application data in its first flight using keys remembered from a previous session ticket. Those bytes are replayable. Safe uses are idempotent reads. State-changing requests should wait for 1-RTT keys. That restriction is the same class of warning TLS 1.3 already makes; QUIC inherits it rather than inventing a new one.
Version negotiation and Retry are extra packet types. Retry lets a server force address validation with a token before it spends CPU on TLS. That defends against a cheap flood of Initial packets spoofed from victim addresses.
Streams
A stream is identified by a 62-bit stream ID. The two low bits encode who opened it and whether it is bidirectional:
- Client-initiated bidirectional IDs are 0, 4, 8, …
- Server-initiated bidirectional IDs are 1, 5, 9, …
- Client-initiated unidirectional IDs are 2, 6, 10, …
- Server-initiated unidirectional IDs are 3, 7, 11, …
HTTP/3 maps each request to a client-initiated bidirectional stream. Control and QPACK encoder/decoder use unidirectional streams. A lost packet that carried only request A does not block request B's STREAM frames from being delivered to the HTTP/3 layer.
Each stream has its own flow-control window. The connection has another window that caps the sum. A sender blocked on one stream can still send on another if connection credit remains. RESET_STREAM aborts a stream without tearing the connection. STOP_SENDING tells the peer to abort its sending side.
Loss recovery and congestion control
QUIC acknowledgements list packet-number ranges, not a single cumulative ACK plus selective holes as a special case. An ACK frame can also carry ACK delay so the receiver's hold time does not inflate RTT.
Probe Timeout (PTO) replaces TCP's retransmission timeout as the maybe-the-path-is-silent timer. Persistent congestion is declared after a long enough burst of loss and folds the congestion window back, analogous in spirit to TCP's response to a timeout.
The RFC does not freeze one congestion controller. Implementations ship NewReno-like logic, CUBIC, BBR, and variants. Because QUIC lives in user space, a browser can change the controller without waiting for an OS update. That is also why a poorly written QUIC stack can harm the path as badly as a poorly written UDP flood: UDP will not pace you.
HTTP/3 on top
HTTP/3 (RFC 9114) is HTTP semantics over QUIC streams. Headers are compressed with QPACK, which is designed so a blocked decoder stream does not stall every request the way HPACK over TCP could. SETTINGS and GOAWAY travel on a control stream. Server push, when enabled, uses server-initiated streams.
Alt-Svc and HTTPS DNS records advertise that a host speaks HTTP/3 on UDP/443. A browser that fails to complete a QUIC handshake falls back to HTTP/2 or HTTP/1.1 over TCP. That fallback is why HTTP/3 everywhere still looks like TCP in packet captures on networks that drop unknown UDP.
A compact mental model
application (HTTP/3, or a custom protocol)
-> QUIC streams (offset, FIN, flow control)
-> QUIC packets (packet number, frames, AEAD)
-> UDP datagram (ports)
-> IP
Compare that with TLS over TCP, where one socket byte stream is both the reliability unit and the application unit. QUIC splits those units on purpose.
What still hurts
UDP/443 is not universally blessed. Corporate firewalls, some hotel networks, and older middleboxes drop or rate-limit it. CPU cost of user-space crypto and ACK processing can exceed kernel TCP with hardware offload, especially on servers that terminate millions of short connections. Connection migration is client-initiated in v1; anycast and load balancers must route by Connection ID or risk breaking a handshake when the path changes.
0-RTT is easy to misuse. So is sending large STREAM frames without respecting MAX_DATA. Debugging is harder than TCP: fewer middleboxes speak QUIC, and encrypted headers hide the packet number from casual tcpdump without SSLKEYLOGFILE-style secrets.
Related protocols on this blog
TCP is the kernel byte-stream QUIC was measured against. UDP is the datagram substrate. HTTP is the application mapping. HTTPS/TLS is the handshake QUIC embeds rather than layering. WebSockets still run on HTTP and TCP in the common deployment; they are a different answer to long-lived messages.
Misconceptions
QUIC is UDP, so it is unreliable. QUIC implements reliability. UDP is how packets leave the host. Loss is repaired per stream unless the application resets that stream.
HTTP/3 is just HTTP/2 on a different port. The frame layout, header compression, and head-of-line behavior all change because the transport changed.
Connection IDs replace encryption. They replace the 4-tuple as the name of the connection. Privacy still depends on TLS and on rotating those IDs when the path changes.
0-RTT is always faster and always safe. It is faster for replay-safe data. It is not a general replacement for waiting on the handshake.
Takeaways
QUIC is a user-space transport that puts TLS 1.3, stream multiplexing, and path migration on top of UDP. Connection IDs name the session. Packet numbers name loss. Stream IDs name independent reliable byte flows. HTTP/3 is the reason most people meet those mechanisms, but the transport is usable for other protocols that want the same shape.
Use TCP when kernel offload, middlebox familiarity, or a single stream is enough. Use QUIC when independent streams, fewer handshake round trips, or survival across address changes are worth the implementation cost. Use raw UDP only when you intend to define reliability yourself — or to refuse it.