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
- 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).
- 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.
- 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.
- 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.
- 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
- UDP GSO +
- 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) andUDP_GRO; setIP_PMTUDISC_PROBE/DF andIP_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
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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| quinn | Rust | quinn-rs/quinn | cargo add quinn | — | — | — | — | async QUIC on tokio, rustls; quinn-proto holds the state machine |
| quiche | Rust | cloudflare/quiche | cargo add quiche | — | — | — | — | Cloudflare; QUIC + HTTP/3, C API, BoringSSL |
| s2n-quic | Rust | aws/s2n-quic | cargo add s2n-quic | — | — | — | — | AWS; heavy testing, fuzzing and formal checks |
| ngtcp2 | C | ngtcp2/ngtcp2 | build from source (autotools or CMake) | — | — | — | — | used by curl and nghttp3; TLS library chosen at build time |
| msquic | C | microsoft/msquic | build 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