Internet Protocols · QUIC and HTTP/3
Reading a QUIC Packet: Header Protection, Varints, and Frame Types
The byte layout of long and short headers, how header protection hides the packet number, and how the frame type space is partitioned between transport and extension use.
A QUIC datagram is a sequence of one or more packets, and each packet is a header followed by a payload that is encrypted before it leaves the sender. The encryption boundary is not at the start of the datagram: the first byte and the packet number field are also obscured, by a separate mechanism that leaves the rest of the header in the clear. Understanding which bytes an observer can see, and which an implementation must reconstruct before it can parse anything, explains most of the design decisions in the format.
The first byte carries the format
Bit 7 of the first byte distinguishes the two header forms. A long header sets it and is used before the handshake completes or when the connection needs to identify itself by connection ID; a short header clears it and is used for ordinary traffic once keys are established. Bit 6 is the fixed bit, which must be 1 in all packets except a version negotiation packet, and exists so that middleboxes which expect a consistent leading pattern do not mistake QUIC for something else.
The remaining six bits mean different things in the two forms. In a long header, bits 5 and 4 are the packet type: Initial, 0-RTT, Handshake, or Retry. Bits 3 and 2 are reserved and must be zero after header protection is removed, and bits 1 and 0 encode the length of the packet number field, as a value one greater than the number of bytes, so a value of 3 means a four-byte packet number.
A long header then carries a four-byte version, a one-byte length prefix followed by that many bytes of destination connection ID, and the same for the source connection ID. Connection IDs are at most 20 bytes, which is the largest value that fits in the space available while keeping the header small enough to be worth the overhead; the length byte permits zero. After the connection IDs the layout depends on the type. An Initial packet adds a token, as a varint length followed by the bytes, then a varint carrying the length of the rest of the packet, then the packet number and payload. A Handshake packet omits the token. A Retry packet has no length field and no separate packet number: its payload is a token followed by a sixteen-byte integrity tag computed over the pseudo-packet.
The short header is leaner because the connection is already known. After the first byte comes the destination connection ID, whose length is not encoded and is fixed by the receiver’s own configuration, then the packet number and payload. Bits 5, 4, and 3 of the first byte are the spin bit and two reserved bits, and bit 2 is the key phase used during key updates.
Variable-length integers and the four width classes
QUIC uses a single integer encoding throughout, including inside frame payloads. The two most significant bits of the first byte give the encoded length: 0 means one byte total and a six-bit value, 1 means two bytes and a fourteen-bit value, 2 means four bytes and a thirty-bit value, 3 means eight bytes and a sixty-two-bit value. Values do not have to use the shortest encoding that fits, with one exception: a frame type must be encoded in the shortest form, so the transport frames are all a single byte. The maximum representable value is 2^62 − 1.
/* RFC 9000 section 16. Decode returns bytes consumed, or 0 on truncation. */
static size_t quic_varint_decode(const uint8_t *p, size_t n, uint64_t *out)
{
if (n == 0) return 0;
size_t len = (size_t)1 << (p[0] >> 6); /* 1, 2, 4, or 8 */
if (n < len) return 0;
uint64_t v = p[0] & 0x3f;
for (size_t i = 1; i < len; i++) v = (v << 8) | p[i];
*out = v;
return len;
}
The encoding is not merely compactness. It lets a sender choose a width that matches the value it is likely to send, and it makes the length of a field knowable from its first byte, so a parser never needs a second pass. It also fixes the size of a stream offset and a length field at eight bytes at most, which bounds the header overhead of a STREAM frame to a predictable maximum.
Frames, and the space reserved for later
The encrypted payload is a sequence of frames, each beginning with a varint type. Frame types 0x00 through 0x1e, and 0x30, are defined by the transport specification: PADDING and PING for liveness, ACK for acknowledgement, RESET_STREAM and STOP_SENDING for stream termination, CRYPTO for handshake data, NEW_TOKEN, STREAM in eight variants, the MAX_* family for flow control, NEW_CONNECTION_ID and RETIRE_CONNECTION_ID for address management, PATH_CHALLENGE and PATH_RESPONSE for path validation, CONNECTION_CLOSE in two variants, and HANDSHAKE_DONE.
The eight STREAM types are not separate frames. The low three bits of the type are flags: bit 2 indicates that an offset field is present, bit 1 that a length field is present, bit 0 that this frame ends the stream. A STREAM frame with none of the flags set carries a stream ID and data that begins at the offset implied by the previous frame, and its data runs to the end of the packet. That last form is the cheapest way to send bulk data, because it omits both the offset and the length, and it relies on the packet boundary to delimit the data.
ACK frames pack ranges rather than individual packets. The frame gives the largest acknowledged packet number, an acknowledgement delay in microseconds scaled by a shift negotiated in transport parameters, the count of additional ranges, the length of the first range, and then for each subsequent range a gap count and a length. Each gap and length is expressed as a difference from the previous range, so an ACK covering a long run with a few holes costs a handful of varints rather than one entry per packet. The delay field is what lets a receiver report the time between arrival and acknowledgement, which the sender uses to correct its round-trip estimate.
Above the transport’s own range, frame types are allocated through an IANA registry, and the transport itself cannot be extended by simply inventing a type. QUIC frames are not self-describing: the payload of a packet is a bare sequence of frames, so a receiver must know every frame’s syntax before it can find the next one. An endpoint that receives an unknown type therefore has no way to skip it, and must close the connection with FRAME_ENCODING_ERROR. Extension frames require a transport parameter that announces willingness to receive them, which is a heavier form of negotiation than HTTP/3 needs at its own layer.
Header protection, and what it actually hides
The payload is encrypted with AEAD, and the header is separately protected so that the packet number and some first-byte bits are not visible to an observer. The mechanism is a stream cipher applied to a mask derived from a sample of the ciphertext.
The sample is taken from the encrypted payload, starting four bytes after the beginning of the packet number field, and is sixteen bytes long for the cipher suites the specification defines. For AES-based suites the mask is the AES-ECB encryption of the sample under the header protection key; for ChaCha20-based suites it is five bytes of the ChaCha20 keystream at a counter derived from the sample. The low four bits of the first byte in a long header, or the low five bits in a short header, are XORed with the low bits of the first mask byte, and the packet number bytes are XORed with the subsequent mask bytes.
The fixed offset is why the packet number field is assumed to be four bytes even when fewer are sent: the receiver must find sixteen bytes of ciphertext at a position that does not depend on a length it cannot yet read, and a packet too short to contain the sample must be discarded. It is also why the packet number length is encoded in bits that are themselves protected. An observer cannot tell how many bytes of packet number follow, so it cannot reliably parse the rest of the header, and a middlebox that tries to track QUIC packets by number has to guess.
Recovering the packet number is a separate problem from decoding it, and the recovered number is what the acknowledgement logic in loss detection without retransmitted packets uses to place each packet in send order. The field carries only the low bits of a number that can grow without bound, and the receiver reconstructs the high bits from the largest packet number it has successfully processed. The encoding is chosen so that the truncated value is unambiguous within a window around that largest number, which is why the sender picks a length based on the difference between the packet number it is sending and the largest it has had acknowledged.
Coalescing and the size floor
A single UDP datagram may contain several packets, provided they are of different types: an Initial followed by a Handshake, for instance, so that the client’s first flight and its response travel together. Each packet carries its own length field in the long header, which is what makes the boundaries discoverable, and a receiver processes them in order, discarding any that fail authentication.
The client’s first datagram must be at least 1,200 bytes, padded with PADDING frames if necessary. The floor serves two purposes. It ensures the server’s anti-amplification budget of three times the received bytes is large enough to carry a certificate chain, and it forces the path to demonstrate that it can carry a datagram of a size that will not be fragmented by ordinary MTU limits. Packets that are too small to contain the header protection sample, or that exceed the path MTU, are the two size failures an implementation has to handle explicitly; the path MTU itself is discovered rather than assumed, as described in path validation and connection migration.