Adib Hanna

Networking 18 min read 39 views

QUIC, From the Packet Up

How QUIC rebuilt the internet's transport on top of UDP: the one-round-trip handshake, streams without head-of-line blocking, loss recovery, connection migration, and what it takes to actually run HTTP/3.

QUIC, From the Packet Up

Every time you open YouTube, scroll Instagram, or load a site behind Cloudflare, there's a good chance none of those bytes travel over TCP.

For about forty years, nearly everything on the internet that needed reliable delivery ran on TCP. Then a big share of web traffic quietly moved somewhere else. It moved to QUIC, a transport protocol that runs on top of UDP, encrypts almost everything it sends, and was published by the IETF as RFC 9000 in 2021. HTTP/3 is HTTP running over QUIC.

I wanted to understand it properly. "It's TCP over UDP, but faster" doesn't cut it for me. I wanted to know what's actually on the wire, why each design decision was made, and what changes when you deploy it.

This post is the result. It's long, a few of the diagrams move (hover over one to pause it), and by the end you'll have run QUIC yourself.

The problem with TCP isn't TCP

TCP is a remarkable protocol. The trouble is how much the modern web asks of it.

A typical HTTPS page load over HTTP/2 stacks three protocols: TCP for reliable delivery, TLS for encryption, and HTTP/2 for multiplexing many requests over one connection. Each layer does its job well. The problems show up where the layers meet.

1. Handshakes stack up. TCP needs a round trip to set up a connection. Only after that can TLS start its own handshake, which costs at least one more round trip with TLS 1.3 (two with TLS 1.2). On a phone with a 100 ms round-trip time, you've spent 200–300 ms before the first byte of HTTP leaves your device.

2. One lost packet stalls everything. HTTP/2 multiplexes dozens of requests over one TCP connection, but TCP has no idea those requests exist. It sees one ordered stream of bytes. If a single segment is lost, every byte after it waits in the kernel until the retransmission arrives, even bytes that belong to completely unrelated requests. This is head-of-line blocking, and on lossy networks it can make HTTP/2 slower than HTTP/1.1 with its six parallel connections.

3. A connection is welded to an address. A TCP connection is identified by four numbers: source IP, source port, destination IP, destination port. Walk out of Wi-Fi range onto cellular and your IP changes, so every TCP connection dies and has to start over: new handshake, new TLS, retry the request.

4. TCP can't change anymore. TCP lives in operating system kernels, and it's inspected by firewalls, NATs and load balancers that think they know what TCP looks like. Changing it means waiting years for operating systems to update, and then finding out which middleboxes break. TCP Fast Open, which lets data ride in the first packet, was standardized in 2014 and never became something you could rely on across the open internet.

None of these are bugs in TCP. They're consequences of where it lives.

Why build on UDP?

If TCP is frozen, the obvious move is a new transport protocol. That's been tried. SCTP has had multiple streams and multi-homing since 2000, and it barely works on the public internet, because home routers and corporate firewalls only pass the protocols they already know: TCP and UDP.

UDP gives you almost nothing: ports, a length, and a checksum. That's exactly why it's useful. Every network passes it, and it adds no behavior you'd have to fight. QUIC puts everything else in user space, inside the application or a library it links:

THE STACKWHAT MOVED WHEREHTTP/2 OVER TCPHTTP/3 OVER QUICHTTP/2streams · HPACK · framingTLS 1.3handshake · encrypted recordsTCPone ordered byte streamloss recovery · congestion controlIPHTTP/3requests · QPACKQUICindependent streamsloss recovery · congestion controlTLS 1.3 handshake built inconnection IDs · migrationUDPIPUSERKERNELUSERKERNELTRANSPORT SHIPS WITH THE OSTRANSPORT SHIPS WITH THE APP
QUIC absorbs the jobs of TCP, TLS and part of HTTP/2 into one layer that runs in user space, on top of UDP. UDP is only there because every network already passes it.

That last row is the big one. When the transport ships with the application, it can change as fast as the application does. Chrome can try a new congestion controller in a release. A server team can fix a loss-recovery bug in a deploy instead of waiting for kernel upgrades across the fleet.

A little history, because it explains some naming you'll run into. Google started QUIC around 2012 and had it running between Chrome and Google's servers within a couple of years. That version (now called gQUIC) used Google's own crypto handshake. The IETF formed a working group in 2016 and ended up redesigning most of it, most notably replacing the custom crypto with TLS 1.3. The result was published in May 2021 as a family of RFCs:

RFC What it covers
8999 Properties every QUIC version must keep
9000 The transport: packets, frames, streams, connection IDs
9001 How TLS 1.3 secures it
9002 Loss detection and congestion control
9114 HTTP/3
9204 QPACK, HTTP/3's header compression
9221 Unreliable datagrams
9369 QUIC version 2

The handshake: one round trip, sometimes zero

Here's the first thing QUIC gets right. TCP and TLS each need their own handshake because they're separate layers that don't trust each other with anything. QUIC is one layer, so it does both at once.

HANDSHAKE · TIME FLOWS DOWNLOOPS · HOVER TO PAUSE01 RTT2 RTT3 RTTTCP + TLS 1.3FRESH CONNECTIONCLIENTSERVERQUICFRESH CONNECTIONCLIENTSERVERQUIC 0-RTTRESUMED SESSIONCLIENTSERVERSYNSYN-ACKACK + ClientHelloServerHello … FinishedFinished + GET /200 OKRESPONSE AT 3 RTTInitial: ClientHelloInitial + HandshakeFinished + GET /200 OKRESPONSE AT 2 RTTClientHello + GET /ServerHello … 200 OKRESPONSE AT 1 RTT
Fetching one page on a fresh connection. TCP needs a round trip of its own before TLS can start; QUIC carries the TLS handshake in its very first packet, and a resumed session can send the request in that same flight. On a 100 ms mobile link, every row is 100 ms of staring at a blank screen.

The client's very first packet is an Initial packet carrying a TLS 1.3 ClientHello. Inside that ClientHello, in a TLS extension, the client also sends its QUIC transport parameters: how much data the server may send it, how many streams the server may open, how long an idle connection survives, and so on. Connection setup and key exchange happen in the same message.

The server answers with its ServerHello in an Initial packet and the rest of its handshake (certificate, signature, Finished) in a Handshake packet, usually packed into the same UDP datagram. At that point the client has everything it needs. It sends its own Finished and the HTTP request together. One round trip.

QUIC doesn't use TLS the usual way, though. There are no TLS records on the wire. QUIC takes the raw handshake messages and carries them in its own CRYPTO frames, and it only uses TLS to agree on keys. QUIC then encrypts every packet itself.

You can see the difference with curl and a real server. These are three back-to-back runs against www.cloudflare.com from my machine; this one is typical:

fmt='dns=%{time_namelookup}s  handshake_done=%{time_appconnect}s  first_byte=%{time_starttransfer}s\n'
curl -so /dev/null -w "h2  $fmt" --http2 https://www.cloudflare.com
curl -so /dev/null -w "h3  $fmt" --http3-only https://www.cloudflare.com
fmt='dns=%{time_namelookup}s  handshake_done=%{time_appconnect}s  first_byte=%{time_starttransfer}s\n'
curl -so /dev/null -w "h2  $fmt" --http2 https://www.cloudflare.com
curl -so /dev/null -w "h3  $fmt" --http3-only https://www.cloudflare.com
h2  dns=0.002941s  handshake_done=0.051187s  first_byte=0.080258s
h3  dns=0.005265s  handshake_done=0.024702s  first_byte=0.052485s
h2  dns=0.002941s  handshake_done=0.051187s  first_byte=0.080258s
h3  dns=0.005265s  handshake_done=0.024702s  first_byte=0.052485s

My round trip to Cloudflare is about 25 ms. HTTP/2 finished its handshake after two round trips and got the first byte after three. HTTP/3 needed one and two. That's the diagram above, measured.

0-RTT, and the catch

When a client has talked to a server before, TLS 1.3 lets it keep a resumption ticket. On the next connection it can derive keys right away and send the request in 0-RTT packets alongside its ClientHello. The response comes back after a single round trip, which is as fast as a network can be.

The catch is that 0-RTT data can be replayed. An attacker who records your first flight can send it to the server again, and the server will accept it, because nothing in those packets proves they're fresh. That's harmless for GET /index.html. It's a disaster for POST /transfer?amount=500.

So the rules are:

  • Only send idempotent, side-effect-free requests in 0-RTT. Browsers stick to safe methods like GET.
  • Servers can refuse 0-RTT entirely, and many do for anything that isn't static content.
  • Proxies that forward early data mark it with an Early-Data: 1 header, and an origin that isn't comfortable with a replay can answer 425 Too Early (RFC 8470), which makes the client retry after the handshake.

0-RTT data also doesn't get full forward secrecy, since it's encrypted with keys derived from the earlier session. Use it for things where speed matters more than these guarantees.

Streams, and the end of head-of-line blocking

This is the design decision everything else in QUIC orbits around: streams are part of the transport.

In HTTP/2, streams are an illusion that TCP doesn't know about. In QUIC, every piece of application data travels in a STREAM frame that says which stream it belongs to and where in that stream it goes (an offset). The receiver reassembles each stream on its own. A gap in one stream says nothing about the others.

ONE LOST PACKETLOOPS · HOVER TO PAUSETCP + HTTP/2ONE ORDERED BYTE STREAMSERVER3 STREAMSRECEIVERDASHED = WAITINGSTREAM ASTREAM BSTREAM CQUIC + HTTP/3EACH STREAM ORDERED ON ITS OWNSERVER3 STREAMSRECEIVERDASHED = WAITINGSTREAM ASTREAM BSTREAM CA1A1B1LOSTC1C1C1A2A2A2B2B2B2C2C2C2B1 DECLARED LOST → RESENTB1B1STALLED · EVERYTHING WAITS FOR B1B1 ARRIVES · ALL RELEASED AT ONCEA1A1B1LOSTC1C1A2A2B2B2B2C2C2B1 DECLARED LOST → RESENTB1B1A AND C KEEP FLOWING · ONLY B WAITSB CATCHES UP · NOTHING ELSE STALLED
The same six packets and the same lost packet. TCP has to hand bytes to the application in order, so C1, A2, B2 and C2 sit in the kernel until B1 is resent, even though only stream B lost anything. QUIC orders bytes per stream, so only stream B waits. Dashed chips have arrived but can't be delivered yet.

Same packets, same loss, very different outcomes. Notice that over TCP, A2 waits even though stream A never lost anything. That's the whole problem in one chip.

Two caveats so you don't oversell this to your team:

  • Bytes within one stream are still delivered in order. If a single large response loses a packet, that response waits. QUIC removes blocking between streams, not within them.
  • Congestion control is still per connection. A loss still makes the sender slow down for everyone. What disappears is the delivery stall, not the reaction to congestion.

Stream IDs

Streams don't need a handshake. To open one, you just send a STREAM frame with a new ID. The two lowest bits of the ID tell both sides who opened it and whether it's one-way:

Low bits Type IDs
0b00 Client-initiated, bidirectional 0, 4, 8, 12…
0b01 Server-initiated, bidirectional 1, 5, 9, 13…
0b10 Client-initiated, unidirectional 2, 6, 10, 14…
0b11 Server-initiated, unidirectional 3, 7, 11, 15…

Because each side numbers its own streams in a different lane, the two endpoints can never pick the same ID, and nobody has to coordinate. The only limit is how many streams the peer allows, which it advertises with MAX_STREAMS and raises as old streams finish.

A stream ends cleanly when the sender sets the FIN bit. It can also be cut short: RESET_STREAM says "I'm abandoning what I was sending," and STOP_SENDING says "I don't want the rest of what you're sending." This is how a browser cancels a request when you navigate away, without touching the connection.

Try it: three requests on one connection

Here's raw QUIC in Go with quic-go (v0.64). The server answers in uppercase, but takes a second for anything starting with "slow". The client opens three streams at once on a single connection:

package main

import (
	"context"
	"crypto/tls"
	"fmt"
	"io"
	"log"
	"strings"
	"sync"
	"time"

	"github.com/quic-go/quic-go"
)

func main() {
	ctx := context.Background()
	cert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
	check(err)

	// Server: accept one connection, then handle every stream concurrently.
	ln, err := quic.ListenAddr("localhost:4242", &tls.Config{
		Certificates: []tls.Certificate{cert},
		NextProtos:   []string{"shout"},
	}, nil)
	check(err)

	go func() {
		conn, err := ln.Accept(ctx)
		check(err)
		for {
			str, err := conn.AcceptStream(ctx)
			if err != nil {
				return
			}
			go func() {
				defer str.Close()
				msg, _ := io.ReadAll(str) // until the client's FIN
				if strings.HasPrefix(string(msg), "slow") {
					time.Sleep(time.Second)
				}
				fmt.Fprintf(str, "stream %d: %s", str.StreamID(), strings.ToUpper(string(msg)))
			}()
		}
	}()

	// Client: one connection, one stream per request, all in flight at once.
	conn, err := quic.DialAddr(ctx, "localhost:4242", &tls.Config{
		InsecureSkipVerify: true, // self-signed demo cert
		NextProtos:         []string{"shout"},
	}, nil)
	check(err)

	var wg sync.WaitGroup
	for _, msg := range []string{"slow request", "fast one", "fast two"} {
		wg.Go(func() {
			str, err := conn.OpenStreamSync(ctx)
			check(err)
			str.Write([]byte(msg))
			str.Close() // FIN: done sending on this stream
			reply, _ := io.ReadAll(str)
			fmt.Println(string(reply))
		})
	}
	wg.Wait()
}

func check(err error) {
	if err != nil {
		log.Fatal(err)
	}
}
package main

import (
	"context"
	"crypto/tls"
	"fmt"
	"io"
	"log"
	"strings"
	"sync"
	"time"

	"github.com/quic-go/quic-go"
)

func main() {
	ctx := context.Background()
	cert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
	check(err)

	// Server: accept one connection, then handle every stream concurrently.
	ln, err := quic.ListenAddr("localhost:4242", &tls.Config{
		Certificates: []tls.Certificate{cert},
		NextProtos:   []string{"shout"},
	}, nil)
	check(err)

	go func() {
		conn, err := ln.Accept(ctx)
		check(err)
		for {
			str, err := conn.AcceptStream(ctx)
			if err != nil {
				return
			}
			go func() {
				defer str.Close()
				msg, _ := io.ReadAll(str) // until the client's FIN
				if strings.HasPrefix(string(msg), "slow") {
					time.Sleep(time.Second)
				}
				fmt.Fprintf(str, "stream %d: %s", str.StreamID(), strings.ToUpper(string(msg)))
			}()
		}
	}()

	// Client: one connection, one stream per request, all in flight at once.
	conn, err := quic.DialAddr(ctx, "localhost:4242", &tls.Config{
		InsecureSkipVerify: true, // self-signed demo cert
		NextProtos:         []string{"shout"},
	}, nil)
	check(err)

	var wg sync.WaitGroup
	for _, msg := range []string{"slow request", "fast one", "fast two"} {
		wg.Go(func() {
			str, err := conn.OpenStreamSync(ctx)
			check(err)
			str.Write([]byte(msg))
			str.Close() // FIN: done sending on this stream
			reply, _ := io.ReadAll(str)
			fmt.Println(string(reply))
		})
	}
	wg.Wait()
}

func check(err error) {
	if err != nil {
		log.Fatal(err)
	}
}

Set up a module, generate a throwaway certificate, and run it. (sync.WaitGroup.Go needs Go 1.25 or newer.)

go mod init quicdemo && go get github.com/quic-go/quic-go@v0.64.0
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
  -keyout key.pem -out cert.pem -days 7 -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost"
go mod tidy && go run .
go mod init quicdemo && go get github.com/quic-go/quic-go@v0.64.0
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
  -keyout key.pem -out cert.pem -days 7 -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost"
go mod tidy && go run .
stream 8: FAST ONE
stream 4: FAST TWO
stream 0: SLOW REQUEST
stream 8: FAST ONE
stream 4: FAST TWO
stream 0: SLOW REQUEST

The stream IDs are 0, 4 and 8, the client-initiated bidirectional lane from the table. The slow request doesn't hold up the fast ones. Which message lands on which ID depends on goroutine scheduling, so your numbers may be shuffled. Also note NextProtos: QUIC requires TLS ALPN, so both sides must agree on an application protocol name. HTTP/3 uses h3.

What a QUIC packet looks like

Time to look at the wire. QUIC has two packet shapes. Long headers are used during the handshake and carry the version plus both connection IDs. Short headers are used for everything after, and carry almost nothing in the clear.

ON THE WIRERFC 9000 · 17ONE UDP DATAGRAM FROM THE SERVER'S FIRST FLIGHTUDP8 BINITIALCRYPTO: ServerHello · ACKHANDSHAKECRYPTO: Certificate · Verify · Finished1-RTTSTREAM: early response dataLONG HEADERS · VERSION + BOTH CONNECTION IDSSHORT HEADERA 1-RTT PACKET, OPENED UPFLAGS1 BDEST CONNECTION ID0–20 B · cleartextPACKET NO.1–4 BENCRYPTED PAYLOADone or more framesAEAD TAG16 B01SRRKPPformfixedspinrsvdkeypn lenSTREAMid 4 · off 0 · len 1100ACKlargest 41 · 2 rangesMAX_DATAcredit: 2 MBCLEARTEXTHEADER-PROTECTEDENCRYPTED + AUTHENTICATED
Top: during the handshake, several QUIC packets can share one UDP datagram. Bottom: after it, almost everything is encrypted. The connection ID stays readable so load balancers can route the packet; the packet number and low flag bits are masked by header protection; the frames are sealed with the payload.

A few things worth noticing:

  • Several packets can share one datagram. The server's first flight usually coalesces an Initial, a Handshake and a 1-RTT packet into a single UDP datagram, because they're encrypted with different keys and so can't be one packet.
  • The short header has no length for the connection ID. Each endpoint chooses the IDs it receives on, so it already knows how long they are.
  • Datagrams have a floor of 1200 bytes. Every QUIC path must carry 1200-byte UDP payloads, and clients pad their first Initial up to that size. Larger sizes are negotiated and probed later.

Variable-length integers

Almost every number in QUIC (stream IDs, offsets, lengths, frame types) is a varint. The top two bits of the first byte tell you how long the integer is:

Top bits Length Usable bits Max value
00 1 byte 6 63
01 2 bytes 14 16,383
10 4 bytes 30 1,073,741,823
11 8 bytes 62 4.6 × 1018

It's small enough to write from memory. These test vectors come straight from RFC 9000's appendix:

def decode_varint(buf: bytes) -> tuple[int, int]:
    """Decode a QUIC variable-length integer. Returns (value, bytes used)."""
    length = 1 << (buf[0] >> 6)   # top two bits: 1, 2, 4 or 8 bytes
    value = buf[0] & 0x3F         # drop the two length bits
    for byte in buf[1:length]:
        value = (value << 8) | byte
    return value, length


def describe_stream(stream_id: int) -> str:
    initiator = "server" if stream_id & 0b01 else "client"
    direction = "uni" if stream_id & 0b10 else "bi"
    return f"{initiator}-initiated, {direction}directional"


assert decode_varint(bytes.fromhex("25")) == (37, 1)
assert decode_varint(bytes.fromhex("4025")) == (37, 2)  # same value, longer form
assert decode_varint(bytes.fromhex("7bbd")) == (15293, 2)
assert decode_varint(bytes.fromhex("9d7f3e7d")) == (494878333, 4)
assert decode_varint(bytes.fromhex("c2197c5eff14e88c")) == (151288809941952652, 8)

print(describe_stream(4))  # client-initiated, bidirectional
print(describe_stream(3))  # server-initiated, unidirectional
def decode_varint(buf: bytes) -> tuple[int, int]:
    """Decode a QUIC variable-length integer. Returns (value, bytes used)."""
    length = 1 << (buf[0] >> 6)   # top two bits: 1, 2, 4 or 8 bytes
    value = buf[0] & 0x3F         # drop the two length bits
    for byte in buf[1:length]:
        value = (value << 8) | byte
    return value, length


def describe_stream(stream_id: int) -> str:
    initiator = "server" if stream_id & 0b01 else "client"
    direction = "uni" if stream_id & 0b10 else "bi"
    return f"{initiator}-initiated, {direction}directional"


assert decode_varint(bytes.fromhex("25")) == (37, 1)
assert decode_varint(bytes.fromhex("4025")) == (37, 2)  # same value, longer form
assert decode_varint(bytes.fromhex("7bbd")) == (15293, 2)
assert decode_varint(bytes.fromhex("9d7f3e7d")) == (494878333, 4)
assert decode_varint(bytes.fromhex("c2197c5eff14e88c")) == (151288809941952652, 8)

print(describe_stream(4))  # client-initiated, bidirectional
print(describe_stream(3))  # server-initiated, unidirectional

Frames

Inside the encrypted payload, a packet is just a sequence of frames. There are about twenty types, and you can understand QUIC pretty well from the handful that do most of the work:

Frame Job
STREAM Application data: stream ID, offset, length, data
ACK Which packet numbers arrived, as ranges
CRYPTO TLS handshake bytes
MAX_DATA / MAX_STREAM_DATA Flow-control credit for the connection / one stream
MAX_STREAMS How many more streams the peer may open
NEW_CONNECTION_ID Spare connection IDs for later
PATH_CHALLENGE / PATH_RESPONSE Prove a new network path works
PADDING / PING Fill a datagram / keep a connection alive
CONNECTION_CLOSE Goodbye, with an error code

Here's a whole STREAM frame by hand. Frame types 0x08 to 0x0f are all STREAM; the low three bits are flags: 0x04 means an offset is present, 0x02 means a length is present, 0x01 is FIN. To send "hi" on stream 4, starting at offset 0, and close the stream:

0b        type: 0x08 | LEN (0x02) | FIN (0x01); no OFF bit, so offset = 0
04        stream ID 4 (varint)
02        length 2 (varint)
68 69     "hi"
0b        type: 0x08 | LEN (0x02) | FIN (0x01); no OFF bit, so offset = 0
04        stream ID 4 (varint)
02        length 2 (varint)
68 69     "hi"

Five bytes, and only three of them are overhead.

Loss detection without ambiguity

TCP numbers bytes. When it retransmits a segment, the copy has the same sequence numbers as the original. So when an ACK comes back, TCP can't always tell whether it's acknowledging the original or the retransmission, and RTT samples taken from retransmitted data get thrown away.

QUIC numbers packets, and packet numbers never repeat. A retransmission isn't a resend of a packet; the frames inside the lost packet get copied into a brand-new packet with a new number.

LOSS RECOVERYLOOPS · HOVER TO PAUSESENDERRECEIVERPN 1PN 1PN 1PN 2PN 2PN 2PN 3PN 3PN 3PN 4PN 4PN 4PN 5PN 5STREAM 4 · off 4800PN 6PN 6PN 6PN 7PN 7PN 7PN 8PN 8PN 8ACK 1–4, 6–8PN 5LOST · 3 LATER PACKETS ACKEDSAME BYTES · NEW PACKET NUMBERPN 9PN 9PN 9ACK 1–4, 6–9 · RTT SAMPLE FROM PN 9 IS EXACT
Packet numbers only go up. When PN 5 goes missing, its STREAM frame is copied into a new packet, PN 9, so every acknowledgement points at exactly one transmission. TCP resends with the old sequence number, which is why it can't always tell which copy an ACK was for. (Real receivers acknowledge every couple of packets; this one waits so the animation fits.)

Since every packet number is sent exactly once, every acknowledgement is unambiguous, and every ACK gives a clean RTT sample. ACK frames also carry an ACK delay field: how long the receiver held the ACK before sending it. The sender subtracts that, which makes QUIC's RTT estimates sharper than TCP's.

ACKs describe ranges, encoded compactly from the top down. The final ACK in the animation above (1–4 and 6–9) looks like this on the wire:

Largest Acknowledged   9
ACK Delay              (encoded receiver delay)
ACK Range Count        1      one extra range after the first
First ACK Range        3      9, 8, 7, 6
Gap                    0      skip one packet (5)
ACK Range Length       3      4, 3, 2, 1
Largest Acknowledged   9
ACK Delay              (encoded receiver delay)
ACK Range Count        1      one extra range after the first
First ACK Range        3      9, 8, 7, 6
Gap                    0      skip one packet (5)
ACK Range Length       3      4, 3, 2, 1

A packet is declared lost when either of these is true (RFC 9002):

  • Packet threshold: a packet sent at least three packets later has been acknowledged.
  • Time threshold: a later packet has been acknowledged, and this one was sent more than 9/8 of a round-trip time ago.

If nothing comes back at all, QUIC uses a probe timeout (PTO) instead of TCP's retransmission timeout. When it fires, the sender sends one or two probe packets to provoke an ACK, rather than assuming the worst and collapsing its sending rate. It only declares persistent congestion when loss lasts for a long stretch.

Retransmitting frames instead of packets has a nice side effect: the sender decides what to resend at the moment it resends. A lost MAX_DATA frame gets resent with the latest value, not a stale one. A lost STREAM frame for a stream the application already cancelled doesn't get resent at all.

Flow control and congestion control

These two get confused a lot, so to keep them straight:

  • Flow control protects the receiver: "don't send me more than I can buffer."
  • Congestion control protects the network: "don't send more than the path can carry."

QUIC's flow control has two levels. Each stream has a limit (MAX_STREAM_DATA), and the whole connection has a limit (MAX_DATA), so one greedy stream can't eat all the buffer. A third limit, MAX_STREAMS, caps how many streams the peer may have open. The starting values travel in the transport parameters during the handshake, and the receiver sends updated limits as the application reads data.

One detail I like: limits are absolute byte offsets, not increments. MAX_DATA 2000000 means "you may send up to byte two million in total." If a flow-control frame is lost or arrives late, the next one simply supersedes it, and the larger number always wins.

In quic-go, those knobs live in quic.Config:

conf := &quic.Config{
	MaxIdleTimeout:                 30 * time.Second,
	KeepAlivePeriod:                10 * time.Second, // keep NAT bindings warm
	InitialStreamReceiveWindow:     512 << 10,        // 512 KB per stream to start
	MaxStreamReceiveWindow:         6 << 20,          // auto-tunes up to 6 MB
	InitialConnectionReceiveWindow: 1 << 20,
	MaxConnectionReceiveWindow:     15 << 20,
	MaxIncomingStreams:             100, // becomes MAX_STREAMS
	Allow0RTT:                      true,
	Tracer:                         qlog.DefaultConnectionTracer, // writes to $QLOGDIR
}
conf := &quic.Config{
	MaxIdleTimeout:                 30 * time.Second,
	KeepAlivePeriod:                10 * time.Second, // keep NAT bindings warm
	InitialStreamReceiveWindow:     512 << 10,        // 512 KB per stream to start
	MaxStreamReceiveWindow:         6 << 20,          // auto-tunes up to 6 MB
	InitialConnectionReceiveWindow: 1 << 20,
	MaxConnectionReceiveWindow:     15 << 20,
	MaxIncomingStreams:             100, // becomes MAX_STREAMS
	Allow0RTT:                      true,
	Tracer:                         qlog.DefaultConnectionTracer, // writes to $QLOGDIR
}

For congestion control, RFC 9002 describes a NewReno-style algorithm as a baseline, but it deliberately doesn't lock implementations into it. Because the transport lives in user space, implementations ship CUBIC, BBR, or their own tuning, sometimes chosen per connection. QUIC also supports ECN: routers can mark packets instead of dropping them, and receivers report those marks in their ACK frames, so the sender can slow down before anything is lost.

Connection IDs and migration

Here's the decision that fixes problem number three. A QUIC connection isn't identified by IPs and ports. It's identified by connection IDs: opaque values, up to 20 bytes, that each endpoint chooses for the packets it receives.

When a packet arrives, the server looks up the destination connection ID, not the address it came from. So when the address changes, the connection survives. Sometimes that happens by accident, such as a NAT that hands out a new port after a quiet period. Sometimes it happens on purpose, such as a phone switching from Wi-Fi to cellular.

CONNECTION MIGRATIONLOOPS · HOVER TO PAUSEPHONECLIENTSERVER203.0.113.5:443DCID 7a1f9e…192.0.2.10:51234WI-FI DROPSPATH LOST198.51.100.7:40122DCID c39e04… · A FRESH ID, SO THE PATHS CAN'T BE LINKEDVALIDATING NEW PATH…PATH_CHALLENGEPATH_RESPONSESAME CONNECTIONno new handshake · streams carry onWI-FICELLULAR
A QUIC connection is named by its connection IDs, not by the IP and port pair, so a new address is just a new path. The client proves the path works with PATH_CHALLENGE and PATH_RESPONSE, switches to a fresh connection ID so an observer can't tie the two paths together, and keeps going. A TCP connection would have died with the old address.

Three details make this work safely:

  • Path validation. Before the server sends much to a new address, it checks that the address is real. The client sends a PATH_CHALLENGE frame with eight random bytes, and the other side has to echo them back in a PATH_RESPONSE. This stops an attacker from redirecting your connection's traffic at a victim.
  • Fresh IDs per path. Endpoints hand each other spare connection IDs in advance with NEW_CONNECTION_ID. When the client migrates, it switches to an unused one, so someone watching both networks can't link the Wi-Fi traffic and the cellular traffic to the same connection.
  • A fresh congestion state. A new path might be much slower or faster than the old one, so the sender resets its congestion controller instead of assuming the old numbers still apply.

In the base protocol, only the client can migrate. A server can offer one alternative address during the handshake (preferred_address), or disable migration entirely with a transport parameter if its infrastructure can't support it.

Connection IDs also help the servers themselves. A load balancer can route by connection ID rather than by address hash, so a client that changes address still lands on the same backend. There's an IETF draft (QUIC-LB) for encoding a server ID into the connection ID so any load balancer can do this without keeping state.

Encryption everywhere, on purpose

TLS over TCP encrypts the payload. Anyone on the path can still see TCP's sequence numbers, ACKs, window sizes and flags, and over the years middleboxes have started depending on all of them. That dependence is exactly what froze TCP.

QUIC encrypts almost everything, ACKs included. After the handshake, an observer sees a few bits of the first byte, the destination connection ID, and ciphertext. Some of this is about privacy, but most of it is about keeping the protocol changeable: anything the network can't see, the network can't freeze.

A few details are easy to miss:

Initial packets aren't secret. Their keys are derived from the client's chosen connection ID and a salt published in the RFC, so anyone on the path can decrypt them, including the server name in the ClientHello. That's deliberate. Initial protection stops off-path attackers from injecting packets and stops middleboxes from casually tampering, but it doesn't stop eavesdropping. Hiding the server name is the job of Encrypted Client Hello (ECH).

Packet numbers are encrypted too. Header protection samples a few bytes of the packet's ciphertext and uses them to mask the packet number and the low bits of the first byte. Without it, observers could track packet numbers across a migration and link the two paths.

The protocol greases itself. Endpoints can randomize the one bit that's supposed to be fixed (RFC 9287), and clients sometimes offer made-up "reserved" version numbers. Both keep middleboxes from assuming things about QUIC that aren't guaranteed. QUIC version 2 (RFC 9369) goes further: it does almost nothing new except change constants, so the internet gets used to more than one version existing.

One bit is left visible on purpose. The optional spin bit flips once per round trip, so network operators can measure latency passively without decrypting anything. It's a small, deliberate gift to the people who run networks, in exchange for taking everything else away.

Not becoming a DDoS amplifier

UDP has a dark side: the source address is trivially forged. If a server answered a small packet with a big one, attackers could spoof a victim's address and use that server to flood the victim. Badly configured DNS and NTP servers have been used exactly like this.

QUIC closes that hole with an anti-amplification limit: until the server has verified the client's address, it may send at most three times as many bytes as it has received. That's why clients pad their first Initial to 1200 bytes. The padding buys the server a 3,600-byte budget for its first flight.

ANTI-AMPLIFICATION LIMITRFC 9000 · 8.1CLIENT INITIALpadded to ≥ 1200 BECDSA CHAINserver flight ≈ 2.4 KBLONG RSA CHAINserver flight ≈ 5.5 KB1,200 BFITS+1.9 KB WAITS 1 RTT3 × 1,200 = 3,600 BUntil the client proves its address, a server may send at most 3× the bytes it received.
Why your certificate chain size suddenly matters. A server that can't fit its handshake into three times the client's first datagram has to stop and wait for the client to reply, which quietly turns a 1-RTT handshake into a 2-RTT one.

This has a very practical consequence. If your certificate chain is large, the server can't fit its whole handshake into the budget, so it sends what it can and waits for the client's next packet. Your one-round-trip handshake has quietly become two. Smaller ECDSA certificates and TLS certificate compression (RFC 8879) both help, and they matter more under QUIC than they did under TCP.

Under heavy load, a server can go further and refuse to keep any state until the client proves its address. It replies with a Retry packet containing a token, and the client has to send its Initial again with that token. This costs a round trip, so servers typically turn it on only when they think they're under attack. A server can also hand a client a NEW_TOKEN for future connections, so a returning client can skip the check.

HTTP/3 on top

With all that machinery in the transport, HTTP/3 itself is fairly thin. Each request gets its own client-initiated bidirectional stream. The request is a HEADERS frame followed by optional DATA frames, and the response comes back on the same stream. When the stream finishes, the exchange is done.

Everything HTTP/2 had to build on top of TCP (stream IDs, per-stream flow control, RST_STREAM) now comes from QUIC. What's left for HTTP/3 is a few unidirectional streams that each side opens at the start:

  • a control stream carrying SETTINGS and, at shutdown, GOAWAY;
  • a QPACK encoder stream and a QPACK decoder stream for header compression.

Prioritization moved out of the framing layer too. Instead of HTTP/2's complicated dependency trees, clients send a simple header defined in RFC 9218: priority: u=0, i, where u is an urgency from 0 to 7 and i means the response can be interleaved with others.

Why HPACK had to go

HTTP/2's header compression, HPACK, keeps a dynamic table of recently used headers on both sides. Every header block may add entries to that table or refer to existing ones, which only works if both sides process header blocks in exactly the order they were sent. TCP guaranteed that. QUIC deliberately doesn't across streams.

QPACK solves this by moving table updates onto the dedicated encoder stream. Each header block says how many table insertions it depends on. If a request's headers arrive before the insertions they need, only that one stream waits, and the decoder tells the encoder how many streams it's willing to let wait (SETTINGS_QPACK_BLOCKED_STREAMS). An encoder that wants zero risk can simply never refer to entries the decoder hasn't confirmed, trading a bit of compression for no blocking at all.

Finding HTTP/3

There's one awkward question left: how does a browser know a server speaks HTTP/3? Nothing in https:// says "try UDP."

HOW A BROWSER FINDS HTTP/3ALT-SVC · LEARN IT ON THE FIRST VISITDNSA / AAAA recordsTCP + TLSHTTP/2 requestRESPONSE HEADERalt-svc: h3=":443"NEXT CONNECTIONQUIC · HTTP/3HTTPS RECORD · LEARN IT FROM DNSDNSHTTPS alpn="h3,h2"FIRST CONNECTIONQUIC · HTTP/3AND ALWAYS A WAY BACKRACEQUIC against TCPUDP BLOCKED?keep the TCP oneREMEMBERskip QUIC for a while
Nothing in a URL says "QUIC", so clients need a hint before they try UDP. The Alt-Svc header costs one visit over TCP; an HTTPS DNS record lets even the first connection use HTTP/3. Either way the browser keeps TCP in its back pocket.

Both hints are easy to see from a terminal:

curl -sI https://www.google.com | grep -i alt-svc
curl -sI https://www.google.com | grep -i alt-svc
alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000

h3=":443" means "HTTP/3 is available on UDP port 443," and ma is how long, in seconds, the client may remember that. The h3-29 entry is a leftover advertisement for draft 29 of the spec, from before the RFC was final.

dig cloudflare.com HTTPS +short
dig cloudflare.com HTTPS +short
1 . alpn="h3,h2" ipv4hint=104.16.132.229,104.16.133.229 ipv6hint=2606:4700::6810:84e5,2606:4700::6810:85e5
1 . alpn="h3,h2" ipv4hint=104.16.132.229,104.16.133.229 ipv6hint=2606:4700::6810:84e5,2606:4700::6810:85e5

That's an RFC 9460 HTTPS record, and it lets a browser use HTTP/3 on the very first connection. (macOS ships a very old dig that prints this record as raw hex. brew install bind gets you one that decodes it.)

Running it yourself

Check your curl

curl -V | grep -o HTTP3
curl --http3 -sI https://cloudflare.com          # try HTTP/3, fall back to TCP
curl --http3-only -so /dev/null -w '%{http_version}\n' https://cloudflare.com
curl -V | grep -o HTTP3
curl --http3 -sI https://cloudflare.com          # try HTTP/3, fall back to TCP
curl --http3-only -so /dev/null -w '%{http_version}\n' https://cloudflare.com

If the first command prints nothing, your curl was built without HTTP/3 support. On macOS, the system curl doesn't have it, but Homebrew's does (brew install curl; it's keg-only, so call it as $(brew --prefix)/opt/curl/bin/curl).

A real HTTP/3 server in Go

A production HTTP/3 server always runs next to an HTTP/1.1 and HTTP/2 server on TCP. Clients arrive over TCP first, learn about HTTP/3 from Alt-Svc, and switch. Here's the smallest correct version of that with quic-go's http3 package:

package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/quic-go/quic-go/http3"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "hello over %s\n", r.Proto)
	})

	h3 := &http3.Server{Addr: ":8443", Handler: mux}

	// HTTP/3 on UDP :8443.
	go func() {
		log.Fatal(h3.ListenAndServeTLS("cert.pem", "key.pem"))
	}()

	// HTTP/1.1 and HTTP/2 on TCP :8443, advertising HTTP/3 via Alt-Svc.
	tcp := &http.Server{
		Addr: ":8443",
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			h3.SetQUICHeaders(w.Header())
			mux.ServeHTTP(w, r)
		}),
	}
	log.Fatal(tcp.ListenAndServeTLS("cert.pem", "key.pem"))
}
package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/quic-go/quic-go/http3"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "hello over %s\n", r.Proto)
	})

	h3 := &http3.Server{Addr: ":8443", Handler: mux}

	// HTTP/3 on UDP :8443.
	go func() {
		log.Fatal(h3.ListenAndServeTLS("cert.pem", "key.pem"))
	}()

	// HTTP/1.1 and HTTP/2 on TCP :8443, advertising HTTP/3 via Alt-Svc.
	tcp := &http.Server{
		Addr: ":8443",
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			h3.SetQUICHeaders(w.Header())
			mux.ServeHTTP(w, r)
		}),
	}
	log.Fatal(tcp.ListenAndServeTLS("cert.pem", "key.pem"))
}

TCP and UDP port numbers are separate namespaces, so both servers can listen on 8443. With the same self-signed certificate as before:

curl -sk -i https://localhost:8443/
curl -sk -i https://localhost:8443/
HTTP/2 200
alt-svc: h3=":8443"; ma=2592000
content-type: text/plain; charset=utf-8
content-length: 20
date: Sat, 17 Oct 2026 14:44:23 GMT

hello over HTTP/2.0
HTTP/2 200
alt-svc: h3=":8443"; ma=2592000
content-type: text/plain; charset=utf-8
content-length: 20
date: Sat, 17 Oct 2026 14:44:23 GMT

hello over HTTP/2.0
curl -sk --http3-only https://localhost:8443/
curl -sk --http3-only https://localhost:8443/
hello over HTTP/3.0
hello over HTTP/3.0

If you deploy this on Linux, two things will bite you. First, open UDP in your firewall, not just TCP. Cloud security groups need a separate UDP rule too:

sudo ufw allow 443/udp
sudo ufw allow 443/udp

Second, QUIC moves a lot of data through UDP sockets, and Linux's default socket buffers are small. quic-go logs a warning about it at startup. Raise the limits:

sudo sysctl -w net.core.rmem_max=7500000
sudo sysctl -w net.core.wmem_max=7500000
sudo sysctl -w net.core.rmem_max=7500000
sudo sysctl -w net.core.wmem_max=7500000

nginx and Caddy

nginx has supported QUIC natively since 1.25. You add a quic listener next to the usual TLS one and advertise it:

server {
    listen 443 quic reuseport;
    listen 443 ssl;
    http2 on;

    server_name example.com;
    ssl_certificate     /etc/ssl/example.com.pem;
    ssl_certificate_key /etc/ssl/example.com.key;

    # Tell browsers HTTP/3 is here; they'll switch on the next connection.
    add_header Alt-Svc 'h3=":443"; ma=86400';

    # Optional: send Retry packets to validate addresses before keeping state.
    # quic_retry on;
}
server {
    listen 443 quic reuseport;
    listen 443 ssl;
    http2 on;

    server_name example.com;
    ssl_certificate     /etc/ssl/example.com.pem;
    ssl_certificate_key /etc/ssl/example.com.key;

    # Tell browsers HTTP/3 is here; they'll switch on the next connection.
    add_header Alt-Svc 'h3=":443"; ma=86400';

    # Optional: send Retry packets to validate addresses before keeping state.
    # quic_retry on;
}

reuseport can only appear on one listen ... quic line per port. If you enable 0-RTT with ssl_early_data on, pass $ssl_early_data to your application so it can refuse unsafe requests.

Caddy serves HTTP/3 automatically. If you're on Caddy, you're probably already speaking QUIC.

Seeing inside

Encryption makes QUIC hard to debug with tcpdump alone, so there are two tools to know.

Key logs with Wireshark. curl (and Chrome, and Firefox) will write their TLS secrets to a file if you set SSLKEYLOGFILE:

export SSLKEYLOGFILE="$HOME/quic-keys.log"
curl --http3-only -so /dev/null https://cloudflare.com
export SSLKEYLOGFILE="$HOME/quic-keys.log"
curl --http3-only -so /dev/null https://cloudflare.com

Point Wireshark at that file (Preferences → Protocols → TLS → (Pre)-Master-Secret log filename), filter on quic, and every packet decrypts: frames, stream IDs, ACK ranges, all of it. Treat the file like a password, because it is one.

qlog. QUIC implementations can emit structured event logs in a standard format called qlog. In quic-go, set Tracer: qlog.DefaultConnectionTracer (as in the config above) and run with QLOGDIR=/tmp/qlog. Drop the files into qvis and you get sequence diagrams, congestion window graphs and stream timelines. It's the best way I know to watch loss recovery happen.

In Chrome, the DevTools Network panel shows h3 in the Protocol column (right-click the column headers to enable it) for requests that went over QUIC.

The costs nobody puts on the slide

QUIC isn't free, and it isn't faster everywhere.

  • CPU. Kernel TCP has decades of optimization and hardware offload behind it. QUIC encrypts and decrypts every packet in user space and makes system calls per datagram, so it costs noticeably more CPU per byte, especially for bulk transfers on fast networks. Batching syscalls (sendmmsg, recvmmsg) and UDP segmentation offload (GSO/GRO) close a lot of the gap, and good implementations use them.
  • Networks that block UDP. Some corporate and public networks block or throttle UDP 443. Clients fall back to TCP, and that fallback costs a little time on those networks.
  • Observability. Your existing tools that read TCP headers see nothing useful. You'll need qlog, key logs, or metrics from the endpoints themselves.
  • Load balancing. A naive L4 load balancer that hashes on IPs and ports will send a migrated connection to the wrong backend. Routing by connection ID fixes it, but your infrastructure has to support it.
  • The best case for QUIC is a bad network. On a clean, low-latency datacenter link, kernel TCP is very hard to beat. QUIC pulls ahead on high-latency, lossy, mobile connections: one fewer round trip, no cross-stream stalls, and connections that survive network changes.

What's being built on QUIC

The most interesting thing about QUIC might be that HTTP/3 is just its first big tenant. Because it's a general secure, multiplexed transport that passes through any network, a lot of new protocols use it:

  • WebTransport gives browsers bidirectional streams and unreliable datagrams over HTTP/3, which is useful for games and real-time apps that outgrew WebSockets.
  • MASQUE (CONNECT-UDP in RFC 9298, CONNECT-IP in RFC 9484) tunnels UDP and IP traffic through HTTP/3 proxies. This is how relay services like Apple's iCloud Private Relay work.
  • DNS over QUIC (RFC 9250) gives encrypted DNS without TCP's handshake cost.
  • Media over QUIC is an IETF effort to build live streaming that scales like a CDN but with much lower latency.
  • Multipath QUIC lets one connection use Wi-Fi and cellular at the same time, instead of migrating between them.
  • Unreliable datagrams (RFC 9221) let applications send data that should never be retransmitted (game state, voice frames) on the same encrypted, congestion-controlled connection as their reliable streams.

The mental model

If you remember one thing, make it this: QUIC keeps TCP's ideas (reliable, ordered, congestion-controlled delivery) and rearranges them around three decisions.

  1. Name connections by ID, not by address, so they survive the network changing underneath them.
  2. Order bytes per stream, not per connection, so one loss only stalls the data it actually affects.
  3. Encrypt nearly everything and live in user space, so the protocol can keep changing, which is the thing TCP lost.

Everything else, from the one-round-trip handshake to packet numbers that never repeat to the 1200-byte padding, follows from those three.

Further reading

  • RFC 9000: the transport itself. Long, but unusually readable for an RFC.
  • RFC 9002: loss detection and congestion control, with pseudocode.
  • RFC 9114: HTTP/3.
  • The QUIC Transport Protocol: Design and Internet-Scale Deployment (SIGCOMM 2017): Google's paper on what they learned running gQUIC at scale.
  • quicwg.org: the working group's drafts, implementations and interop results.