Home › Networking › Transport & congestion control
algorithm medium

QUIC

Problem Applications on slow, lossy or mobile networks need a connection that starts quickly, survives a change of network, and does not let one lost packet stall unrelated data, all while staying encrypted.

Encrypted, multiplexed transport over UDP with 1-RTT setup, independent streams and connection migration; the transport of HTTP/3.

1-RTT handshake (0-RTT on resumption); O(1) per packet plus AEAD at order of 1-5 GB/s per core
transportudptls-1-3http3streamsrfc-9000
Overview

TCP + TLS costs 2-3 round trips before data, blocks all HTTP/2 streams behind one lost packet, and is frozen by middleboxes that parse its headers. QUIC moves the transport into user space on top of UDP, merges the TLS 1.3 handshake into the transport handshake, encrypts almost every header bit, and gives each stream its own ordering. Packets carry ever-increasing numbers, so retransmissions are never ambiguous.

Key facts

Use when

  • Web/API traffic to mobile or lossy clients (HTTP/3).
  • You need multiplexed streams plus unreliable datagrams (RFC 9221) in one encrypted connection: games, media, tunnels.
  • Web (HTTP/3, RFC 9114), file sharing (SMB over QUIC) and name resolution (DNS over QUIC, RFC 9250).

Avoid when

  • Inside a datacenter at 10-100 Gbit/s per host: kernel TCP with TSO/GRO, kTLS and NIC offload is far cheaper.
  • Networks that block or rate-limit UDP 443; always keep a TCP fallback.

Deep dive

How it works

Parts:

  • Packets — long header during setup, short header after; three number spaces, AEAD plus header protection
  • Handshake — TLS 1.3 messages in CRYPTO frames, transport parameters, address validation
  • Streams — independent ordered byte channels with per-stream and connection flow control
  • Loss detection and congestion control — RFC 9002 timers; NewReno baseline, CUBIC or BBR in practice
  • Connection IDs — route packets and let a connection survive address changes
  1. Packets: long header (Initial, 0-RTT, Handshake, Retry) during setup, short header (1-RTT) after. Three packet number spaces (Initial, Handshake, Application), each with its own ACKs. Packet numbers are 62-bit, truncated to 1-4 bytes on the wire and protected by header protection (mask from a sample of the ciphertext).
  2. Handshake: client Initial carries the TLS ClientHello in CRYPTO frames and is padded to ≥ 1200 bytes. Until the client address is validated, the server may send at most 3x what it received (anti-amplification); a Retry with a token validates the address at the cost of 1 RTT.
  3. Frames inside packets: STREAM (id, offset, data), ACK (largest, ack_delay, ranges), CRYPTO, MAX_DATA / MAX_STREAM_DATA / MAX_STREAMS (flow control at connection and stream level), NEW_CONNECTION_ID, PATH_CHALLENGE/RESPONSE, DATAGRAM.
  4. Streams: id low bits = initiator (client/server) and direction (bidi/uni). Lost frames are re-sent in new packets with new numbers; data, not packets, is retransmitted.
  5. Loss detection (RFC 9002): a packet is lost if a packet ≥ 3 numbers later was acked, or if it was sent more than 9/8 · max(srtt, latest_rtt) before an acked one. Probe timeout PTO = srtt + max(4·rttvar, 1 ms) + max_ack_delay, doubling on each expiry. Congestion control is pluggable; the RFC baseline is NewReno, deployments use CUBIC or BBR.
TCP+TLS1.3:  SYN → SYN/ACK → ClientHello → ServerHello..Fin → request   (2 RTT)
QUIC:        Initial[ClientHello] → Initial+Handshake[ServerHello..Fin] → 1-RTT request (1 RTT)
QUIC 0-RTT:  Initial[ClientHello] + 0-RTT[request] →                      (0 RTT)

Code

/// QUIC variable-length integer (RFC 9000 §16): 2-bit length prefix, 6/14/30/62 value bits.
pub fn put_varint(buf: &mut Vec<u8>, v: u64) {
    match v {
        0..=0x3f => buf.push(v as u8),
        0x40..=0x3fff => buf.extend_from_slice(&((v as u16) | 0x4000).to_be_bytes()),
        0x4000..=0x3fff_ffff => buf.extend_from_slice(&((v as u32) | 0x8000_0000).to_be_bytes()),
        0x4000_0000..=0x3fff_ffff_ffff_ffff => buf.extend_from_slice(&(v | 0xc000_0000_0000_0000).to_be_bytes()),
        _ => panic!("varint out of range"),
    }
}

/// Returns (value, bytes consumed) or None if truncated.
pub fn get_varint(b: &[u8]) -> Option<(u64, usize)> {
    let first = *b.first()?;
    let len = 1usize << (first >> 6);
    if b.len() < len { return None; }
    let mut v = (first & 0x3f) as u64;
    for &x in &b[1..len] { v = (v << 8) | x as u64; }
    Some((v, len))
}

/// Bytes needed to encode `pn` so the peer can decode it (RFC 9000 A.2).
pub fn pn_len(pn: u64, largest_acked: Option<u64>) -> usize {
    let unacked = match largest_acked { Some(a) => pn - a, None => pn + 1 };
    let bits = 64 - unacked.leading_zeros() as usize + 1; // need 2x the range
    ((bits + 7) / 8).clamp(1, 4)
}

/// Recover the full packet number from its truncated form (RFC 9000 A.3).
pub fn decode_pn(largest: u64, truncated: u64, nbits: u32) -> u64 {
    let expected = largest + 1;
    let win = 1u64 << nbits;
    let hwin = win / 2;
    let cand = (expected & !(win - 1)) | truncated;
    if cand + hwin <= expected && cand < (1u64 << 62) - win {
        cand + win
    } else if cand > expected + hwin && cand >= win {
        cand - win
    } else {
        cand
    }
}

/// RTT estimator (RFC 9002 §5).
pub struct Rtt { pub min: f64, pub srtt: f64, pub var: f64, first: bool }
impl Rtt {
    pub fn new() -> Self { Rtt { min: f64::INFINITY, srtt: 0.333, var: 0.333 / 2.0, first: true } }
    pub fn update(&mut self, latest: f64, ack_delay: f64, max_ack_delay: f64) {
        self.min = self.min.min(latest);
        if self.first { self.srtt = latest; self.var = latest / 2.0; self.first = false; return; }
        let d = ack_delay.min(max_ack_delay);
        let adj = if latest >= self.min + d { latest - d } else { latest };
        self.var = 0.75 * self.var + 0.25 * (self.srtt - adj).abs();
        self.srtt = 0.875 * self.srtt + 0.125 * adj;
    }
    pub fn pto(&self, max_ack_delay: f64) -> f64 { self.srtt + (4.0 * self.var).max(0.001) + max_ack_delay }
    pub fn loss_delay(&self, latest: f64) -> f64 { (9.0 / 8.0 * self.srtt.max(latest)).max(0.001) }
}

#[test]
fn rfc_examples() {
    assert_eq!(get_varint(&[0x9d, 0x7f, 0x3e, 0x7d]), Some((494_878_333, 4)));
    assert_eq!(get_varint(&[0x7b, 0xbd]), Some((15_293, 2)));
    assert_eq!(decode_pn(0xa82f30ea, 0x9b32, 16), 0xa82f9b32);
}

Complexity and constants

  • Handshake: 1 RTT (≈ 2 packets each way with a ~3-5 KB certificate chain; large chains can hit the 3x amplification limit and add a round trip). Use compact chains or ECDSA certs.
  • Max UDP payload: endpoints must support 1200 bytes; PMTU discovery (DPLPMTUD) can raise it to ~1450 on Ethernet paths.
  • Defaults: initial RTT 333 ms, max_ack_delay 25 ms, ACK every 2 ack-eliciting packets.
  • Per-packet overhead: short header 1 + DCID (often 8) + 1-4 PN bytes, plus 16-byte AEAD tag.
  • CPU: AEAD at order of 1-5 GB/s/core (AES-GCM with AES-NI); the real cost is syscalls and per-packet work. sendmmsg + UDP GSO (Linux 4.18) send up to 64 segments per call; UDP GRO (5.0) on receive.

Efficiency

  • Cost: handshake 1 RTT (TCP + TLS 1.3: 2 RTT); per packet a 1-byte short header, the connection ID (often 8 B), 1-4 B packet number and a 16 B AEAD tag. CPU order of 2x TCP+TLS per byte, bound by per-packet syscalls and crypto (AEAD order of 1-5 GB/s per core), not bandwidth; connection state order of 10 KB.
  • Levers:
    • UDP GSO + sendmmsg — up to 64 segments per send call (Linux 4.18) — bulk transfers
    • UDP GRO + recvmmsg — fewer per-packet receive traversals (Linux 5.0) — bulk receive
    • 0-RTT resumption — saves one RTT (RFC 9001) — idempotent requests only
    • compact certificate chain (ECDSA) — stays under the 3x anti-amplification limit, avoiding an extra round trip (RFC 9000) — first connections
    • pacing and windows scaled to the bandwidth-delay product — no bursts, no window-limited throughput (RFC 9002) — long paths
  • Naive → standard → state of the art:
  • New TCP + TLS 1.3 connection per request · 2 RTT before the first byte
  • QUIC, one syscall per packet · 1 RTT, syscall-bound
  • QUIC with GSO/GRO batching, 0-RTT resumption, connection-ID-routed L4 balancing · 0-1 RTT, syscalls amortized over a batch; in datacenters kernel TCP with kTLS and NIC offload stays cheaper

Implementation notes

  • Batch I/O: recvmmsg/sendmmsg, UDP_SEGMENT (GSO) and UDP_GRO; set IP_PMTUDISC_PROBE/DF and IP_RECVERR.
  • Route by connection ID, not 4-tuple: put a server ID in the CID so an L4 load balancer (Maglev) can find the right backend after migration.
  • Keep per-connection state compact (order of 10 KB plus buffers); 100k connections per box is normal.
  • Pace sending (BBR or paced CUBIC): user space has no qdisc to smooth bursts.
  • Stateless reset: derive the 16-byte token with an HMAC of the CID so any server can reset a dead connection.

Pitfalls

  • 0-RTT data is replayable; accept only idempotent requests and use anti-replay (single-use tickets or a time window).
  • Coalescing: one UDP datagram may hold Initial + Handshake + 1-RTT packets; parse all of them.
  • Flow-control windows too small (default 64-256 KB in some stacks) cap throughput at window/RTT; scale to BDP.
  • Middleboxes and enterprise firewalls drop UDP; race QUIC against TCP (Happy Eyeballs style) and cache the result.
  • Packet-number decoding off by one window corrupts the AEAD nonce; every packet then fails to decrypt.

Variants and alternatives

TCP + TLS 1.3 + HTTP/2cheaper CPU, kernel and NIC offload; the right choice server-to-server.
Reliable UDP game protocolswhen you need per-message reliability policies and minimal headers; QUIC DATAGRAM frames cover part of this.
WebTransportQUIC streams and datagrams exposed to browsers over HTTP/3.

In production

Google (gQUIC since 2013, IETF QUIC now), Cloudflare (quiche), Meta (mvfst), Microsoft (msquic, in Windows and .NET), AWS (s2n-quic), Fastly/LiteSpeed (lsquic), ngtcp2 (curl), quinn (Rust). Chrome, Firefox and Safari speak HTTP/3.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
quinnRustquinn-rs/quinncargo add quinn————async QUIC on tokio, rustls; quinn-proto holds the state machine
quicheRustcloudflare/quichecargo add quiche————Cloudflare; QUIC + HTTP/3, C API, BoringSSL
s2n-quicRustaws/s2n-quiccargo add s2n-quic————AWS; heavy testing, fuzzing and formal checks
ngtcp2Cngtcp2/ngtcp2build from source (autotools or CMake)————used by curl and nghttp3; TLS library chosen at build time
msquicCmicrosoft/msquicbuild from source (CMake)————Microsoft; in Windows and .NET; user-space and kernel modes

Sources

  • Iyengar, Thomson, RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport, 2021. rfc
  • Thomson, Turner, RFC 9001: Using TLS to Secure QUIC, 2021; Iyengar, Swett, RFC 9002: QUIC Loss Detection and Congestion Control, 2021. 9001 · 9002
  • Bishop, RFC 9114: HTTP/3, 2022; Pauly, Kinnear, Schinazi, RFC 9221: An Unreliable Datagram Extension to QUIC, 2022. 9114 · 9221
  • Langley et al., The QUIC Transport Protocol: Design and Internet-Scale Deployment, ACM SIGCOMM, 2017. paper

Builds on

Used by

Systems (9):

Alternatives and related