Networking

QUIC: Rebuilding TCP on Top of UDP

QUIC is a modern transport protocol — the layer beneath HTTP/3 — that carries reliable, encrypted, multiplexed data over UDP instead of TCP. That sounds backwards: UDP is the unreliable, connectionless protocol. But by rebuilding reliability, ordering, and congestion control in user space on top of UDP's bare datagrams, QUIC escapes three problems that were welded into TCP's decades-old design: head-of-line blocking across independent requests, a slow multi-round-trip handshake, and a connection identity that dies the moment your phone switches from Wi-Fi to cellular.
  • StandardRFC 9000/9001/9002 (May 2021)
  • Handshake1-RTT (0-RTT on resume) vs 2-RTT TCP+TLS
  • SubstrateUDP, min 1200-byte datagram
  • Connection ID0–20 bytes, survives IP change
  • Streamsup to 2^62 independent, 62-bit IDs
  • Anti-amplification3× received bytes before path validation

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

The flaw QUIC targets: one byte stream, one stall

TCP presents the application with a single, in-order byte stream. That abstraction is the source of its most stubborn latency problem. TCP delivers bytes to the application strictly in sequence-number order, so if segment N is lost, every byte that arrives after it — even if those bytes are sitting complete in the receiver's buffer — is withheld until the retransmission of N finally arrives, one round trip later. This is head-of-line (HoL) blocking.

HTTP/2 made this worse, not better. To avoid opening six parallel TCP connections, HTTP/2 multiplexes dozens of requests as logical streams over a single TCP connection. But TCP underneath still sees one byte stream and knows nothing about those streams. A single lost segment carrying a fragment of one image now stalls the bytes of every concurrent request — CSS, JavaScript, other images — behind it. On a link with even 1–2% packet loss, HTTP/1.1 with parallel connections could beat HTTP/2, because loss on one connection didn't freeze the others. QUIC's central move is to push the notion of independent streams down into the transport, so the transport itself can deliver stream B while stream A waits for a retransmit.

Streams and frames: the data model

A QUIC connection carries many streams, each an independently ordered, flow-controlled sequence of bytes. Streams are cheap: a 62-bit Stream ID (encoded as a variable-length integer) labels each one, and the two low bits encode who opened it (client or server) and whether it is bidirectional or unidirectional. Ordering is guaranteed within a stream but not across streams — which is exactly what breaks the HoL chain.

On the wire, data does not travel as "stream bytes" directly. A QUIC packet (one UDP datagram may hold one or more) contains a sequence of typed frames: STREAM frames carry application bytes tagged with a stream ID and byte offset; ACK frames report received packet ranges; CRYPTO frames carry the TLS handshake; and control frames like MAX_DATA, MAX_STREAM_DATA, RESET_STREAM, and PATH_CHALLENGE manage the connection. Because each STREAM frame is self-describing (stream + offset), losing the packet that held stream A's frame requires retransmitting only that data; stream B's frames, in other packets, are delivered immediately.

  • Two-level flow control. QUIC enforces credit both per-stream (MAX_STREAM_DATA) and per-connection (MAX_DATA), plus a cap on the number of open streams (MAX_STREAMS). This prevents one fast stream from starving the connection and bounds receiver memory.
  • Compact encoding. Lengths, offsets, and IDs use variable-length integers (varints): the top two bits of the first byte select a 1-, 2-, 4-, or 8-byte width, encoding values up to 2^62−1 with almost no overhead for small numbers.

The folded handshake: transport and TLS 1.3 as one

Classic HTTPS setup is two handshakes stacked: TCP's SYN / SYN-ACK (one round trip) followed by the TLS handshake (another round trip in TLS 1.3), for roughly 2 RTT before the first byte of a request can leave. QUIC fuses them. The TLS 1.3 handshake is not layered on top of QUIC — it is carried inside QUIC's own CRYPTO frames, and its key schedule directly produces the keys that protect QUIC packets (RFC 9001). The result is a 1-RTT handshake that establishes both the connection and end-to-end encryption together.

On resumption, QUIC can do 0-RTT: using a pre-shared key from a prior session ticket, the client sends application data in its very first flight, before any reply. The catch is fundamental, not incidental — 0-RTT data is not forward-secret and can be replayed by an attacker who captures the first packet, so QUIC and HTTP/3 restrict 0-RTT to idempotent operations (a GET, never a bank transfer).

Encryption is not optional and reaches almost everything. QUIC uses three separate packet number spaces — Initial, Handshake, and Application (1-RTT) — each with independent keys and ACKs. Payloads are protected with AEAD; a separate header protection step encrypts the packet number and header flag bits by XORing them with a mask sampled from the ciphertext. Initial packets are 'encrypted' with keys derived by HKDF from a fixed, version-specific salt and the client's chosen connection ID — offering no secrecy, but versioning the wire format and defeating trivial off-path tampering. There is no cleartext transport header for a middlebox to read or rewrite.

Loss recovery without ambiguity

QUIC re-implements reliability, but fixes a subtlety TCP has carried since the 1980s. In TCP, the sequence number is a byte offset; a retransmitted segment reuses the same sequence number as the original. When an ACK arrives, the sender cannot tell whether it acknowledges the first transmission or the retransmission — the retransmission ambiguity that Karn's algorithm works around by simply discarding RTT samples from retransmitted segments.

QUIC assigns a fresh, strictly increasing packet number to every packet it ever sends, including retransmissions, and never reuses one. A lost STREAM frame is resent in a new packet with a new number, while the same stream bytes keep their original offset. So every ACK identifies exactly one transmission: RTT samples are always clean, and spurious-retransmit detection is exact. QUIC's ACK frames are SACK-like, reporting multiple received ranges (and ECN counts for ECT(0), ECT(1), and CE marks), so the sender has a precise picture of exactly which packets landed (RFC 9002).

Congestion control lives per connection, in user space, and is pluggable. RFC 9002 specifies a NewReno-style default, but real deployments run CUBIC or BBR — the same window/pacing algorithms as TCP, now operating on QUIC's cleaner signals. A Probe Timeout (PTO) replaces TCP's coarse RTO for tail-loss recovery. Because the algorithm ships with the application rather than the kernel, a server operator can deploy a new congestion controller by updating a library, not by waiting for an OS release.

Connection IDs and migration

TCP identifies a connection by the 4-tuple — source IP, source port, destination IP, destination port. Change any element and the connection is, by definition, a different one. This is why walking out of the house drops your download: your phone hands off from Wi-Fi to cellular, your source IP changes, the 4-tuple is invalid, and TCP must tear down and reconnect (a fresh handshake, fresh slow-start).

QUIC identifies a connection by an opaque Connection ID (0–20 bytes in QUIC v1), carried in the packet header and independent of addresses. When the client's address changes, the server keeps receiving packets bearing the same Connection ID and simply continues the connection on the new path — no reconnect, no lost handshake. Each side supplies a pool of Connection IDs to the other so identifiers can rotate and not become a cross-network tracking cookie. Datacenter load balancers also exploit this: they encode routing information into the server-chosen Connection ID (the QUIC-LB scheme) to steer every packet of a migrated connection to the same backend.

Migration opens an attack surface, which QUIC closes with two mechanisms. Before trusting a new address, an endpoint runs path validation — a PATH_CHALLENGE with a random token that must be echoed in a PATH_RESPONSE — proving the peer actually receives packets there. And to stop attackers from using a spoofed source address to make a server flood a victim, an anti-amplification limit caps the server at sending at most 3× the bytes it has received from an unvalidated address (which is also why Initial packets must be padded to at least 1200 bytes — giving the server enough budget to complete the handshake).

User space, deployment, and the costs

Perhaps QUIC's most consequential design choice is architectural: it runs in user space, inside the browser or server library, not the OS kernel. TCP's evolution is glacial precisely because it lives in kernels and in the middleboxes (NATs, firewalls, 'accelerators') that inspect and rewrite TCP headers — a phenomenon called ossification. By moving to UDP and encrypting nearly the whole header, QUIC becomes opaque to middleboxes and free to evolve at software-release speed.

The lineage is real and industrial. Google shipped an experimental precursor ("gQUIC") in Chrome and its servers around 2013; its 2017 SIGCOMM deployment paper reported that QUIC then carried roughly a third of Google's egress traffic and delivered measurable wins — on the order of ~8% lower Google Search latency and ~18% fewer YouTube rebuffers on desktop — largely from the reduced-RTT handshake and loss resilience. The IETF standardized QUIC as RFC 9000 in 2021, and HTTP/3 (RFC 9114, with QPACK header compression to avoid HoL in the compressor) now ships in Chrome, Edge, Firefox, and Safari. Independent stacks abound: Cloudflare's quiche, Microsoft's msquic, Meta's mvfst, Google's Chromium stack, ngtcp2, lsquic, and quic-go.

The costs are honest ones. Doing per-packet AEAD and framing in user space historically cost roughly 2× the CPU per byte versus kernel TCP; the gap has narrowed with UDP segmentation/receive offload (GSO/GRO), sendmmsg batching, and NIC crypto offload. Some networks throttle or block UDP, and a small fraction of QUIC connections still fall back to TCP. And HoL blocking is only banished across streams — within a single stream, loss still delays that stream's later bytes, and a lost UDP datagram still loses whatever frames it happened to carry. QUIC did not repeal the laws of packet loss; it stopped one lost packet from taking everything else hostage.

TCP + TLS 1.3 versus QUIC (the transport under HTTP/3)
PropertyTCP + TLS 1.3QUIC / HTTP/3
Where it runsOS kernelUser space (app library)
Setup before app data~2 round trips1 round trip (0-RTT on resume)
Data modelone ordered byte streammany independent streams
A single packet lossstalls all data behind itstalls only its own stream
Connection identity4-tuple (IPs + ports)Connection ID (survives IP change)
What is encryptedpayload; TCP header in clearpayload + almost all header fields

Frequently asked questions

Why build a reliable protocol on UDP instead of just fixing TCP?

TCP lives in the OS kernel and is inspected by countless middleboxes that rewrite its headers, so any change must propagate through operating systems and network hardware worldwide — a process measured in decades (this is called ossification). UDP is a thin, unmodifiable datagram service, so QUIC can implement reliability, ordering, and congestion control in a user-space library and encrypt the whole header, letting the transport evolve at the speed of a software update.

Does QUIC actually eliminate head-of-line blocking?

It eliminates it across independent streams: a packet loss affecting one HTTP request no longer stalls the others, which is TCP's and HTTP/2's core weakness. It does not eliminate ordering within a single stream — a loss still delays that stream's own later bytes — and a lost UDP datagram still loses whatever frames it carried. The win is isolation between concurrent transfers.

How is QUIC's handshake faster than HTTPS over TCP?

Traditional HTTPS needs a TCP handshake (one round trip) and then a separate TLS handshake (another round trip), roughly 2 RTT before any request. QUIC carries TLS 1.3 inside its own crypto frames and derives its packet-protection keys from the TLS key schedule, collapsing both into a single round trip. On a resumed session it can send application data with the very first packet (0-RTT), though 0-RTT data is replayable and is limited to idempotent requests.

What is a Connection ID and why does it matter?

It is an opaque identifier (0–20 bytes in QUIC v1) in the packet header that names the connection independently of IP addresses and ports. Because the connection is keyed on the Connection ID rather than the 4-tuple, it survives a client's network change — Wi-Fi to cellular, or a NAT rebinding — without reconnecting, and datacenter load balancers can encode routing hints into it to keep every packet flowing to the same backend.

Is QUIC always encrypted, including the headers?

Yes. Encryption is mandatory and integrated with TLS 1.3: payloads use AEAD, and a header-protection step encrypts the packet number and flag bits. Almost the entire header is protected, leaving middleboxes nearly nothing in the clear to read or modify — a deliberate anti-ossification and privacy property, unlike TCP whose headers travel in plaintext.

Does QUIC use CUBIC or BBR for congestion control?

Both are used in practice. RFC 9002 defines a NewReno-style default, but production stacks run CUBIC or BBR — the same algorithm families as TCP. The difference is that they live in user space per connection and operate on QUIC's unambiguous, uniquely-numbered ACKs, so operators can swap or tune the controller by updating a library instead of shipping a kernel.