skip to content

questions

4

Walk through how HPACK, the header compression format used by HTTP/2, encodes a header field. What do the static table, the dynamic table and Huffman coding each contribute?

level: middleimportance: must knowfreq 45%

answer

  1. static 61 entries, dynamic from index 62
  2. entry size = name + value + 32
  3. FIFO eviction, default 4096 bytes
  4. indexed / incremental / no-index / never-indexed
  5. Huffman with H bit, ~20-30%, skip for random data

basics

~20 s

HPACK encodes each field as an index or a literal. A 61-entry static table covers common fields; a per-connection dynamic table (FIFO, byte-bounded) holds fields already seen, so repeats become one index byte. Literal strings are Huffman-coded. Both peers must keep tables in sync.

solid answer

~60 s

HPACK (RFC 7541) represents a header field in one of three ways: - **Indexed field** - the whole `name: value` pair is already in a table, so it is sent as a single index (often one byte). Example: `:method: GET` is static index 2. - **Literal with incremental indexing** - the name may be indexed, the value is sent literally, and the pair is then *inserted into the dynamic table* so later occurrences become an index. - **Literal without indexing / never indexed** - sent literally and not stored, for values that change every request or are sensitive. The **static table** has 61 fixed entries known to both sides (indices 1-61). The **dynamic table** starts empty per connection and per direction, grows as fields are indexed, and evicts FIFO once it exceeds the negotiated size (default 4096 bytes, each entry costing name + value + 32 bytes of overhead). Dynamic entries are addressed from index 62 upward. Any string still sent literally may be **Huffman-coded** with a fixed canonical code tuned to header characters, saving roughly 20-30%. The critical property: the encoder mutates decoder state, so both tables must be updated in exactly the same order.

code

http · 15 lines
http
# first request on the connection
:method: GET                -> static index 2                (1 byte)
:scheme: https              -> static index 7                (1 byte)
:path: /a                   -> literal, no indexing          (~4 bytes)
:authority: example.com     -> literal + INSERT into dynamic (~10 bytes)
user-agent: Mozilla/5.0 ... -> literal + INSERT into dynamic (~80 bytes huffman)
cookie: session=8f3a...     -> literal + INSERT into dynamic (~40 bytes huffman)

# second request, same connection
:method: GET                -> static index 2                (1 byte)
:scheme: https              -> static index 7                (1 byte)
:path: /b                   -> literal, no indexing          (~4 bytes)
:authority: example.com     -> dynamic index                 (1 byte)
user-agent: Mozilla/5.0 ... -> dynamic index                 (1 byte)
cookie: session=8f3a...     -> dynamic index                 (1 byte)

go deeper

for a junior

Know the three ingredients by name - static table, dynamic table, Huffman - and that a repeated header becomes a small index.

for a middle

Be able to describe the representations, the FIFO byte-bounded dynamic table with the +32 per-entry cost, and why volatile fields should not be indexed.

for a senior

Add the operational angle: per-connection, per-direction state; COMPRESSION_ERROR kills the connection; encoder policy determines the real ratio far more than the format does.

for a principal

Emphasise that the format is a stateful ordered mutation protocol, and that this single design property is what forced QPACK for HTTP/3 and what bounds intermediary memory at the edge.

## The model HPACK is not a general-purpose compressor. It is a small stateful codec that knows it is compressing a *list of name/value pairs*, repeated many times, over one connection. Each direction of an HTTP/2 connection has its own encoder/decoder pair and its own table state. The client's request encoder and the server's request decoder must stay bit-for-bit in agreement; the same holds independently for responses. ## The static table 61 entries, fixed in the RFC, listing the most common header fields observed in real traffic. Some entries carry a name *and* a value (index 2 is `:method: GET`, index 8 is `:status: 200`); others carry only a name with an empty value (index 32 is `cookie`, index 1 is `:authority`). It costs nothing to maintain - it is a constant compiled into every implementation - and it makes the very first request on a fresh connection already compact. ## The dynamic table A FIFO list, empty at connection start, into which the encoder may insert `name: value` pairs. It is addressed in the same index space as the static table, continuing from 62, with the most recently inserted entry at the lowest dynamic index. Its size is measured not in entries but in bytes: each entry counts as `len(name) + len(value) + 32`, the 32 being a fixed allowance for per-entry bookkeeping so that many tiny entries cannot blow up memory. The maximum size is bounded by `SETTINGS_HEADER_TABLE_SIZE`, which the *decoder* advertises (default 4096 bytes); the encoder may choose to use less and signals its choice with a dynamic table size update instruction. Adding an entry that would exceed the limit evicts from the oldest end until it fits; an entry larger than the whole table evicts everything and is itself not stored. This is what turns a 900-byte browser header block into roughly 30 bytes on the second request: `user-agent`, `accept-language`, `cookie` and friends are all sitting in the dynamic table, each addressable by one small index. ## Representations, concretely The first bits of each field's encoding select the representation: - `1xxxxxxx` - **indexed header field**: the remaining bits are the index; the full pair is reconstructed from the table. - `01xxxxxx` - **literal with incremental indexing**: name given as index or literal, value literal; the resulting pair is *appended to the dynamic table*. - `0000xxxx` - **literal without indexing**: sent as-is, not stored, but an intermediary re-encoding the message is free to index it. - `0001xxxx` - **literal never indexed**: sent as-is, not stored, and the sender is instructing *every* downstream intermediary never to index it either. This is the representation for sensitive values. - `001xxxxx` - dynamic table size update. Integers use a prefix encoding: small values fit in the remaining bits of the first byte, larger ones spill into continuation bytes. ## Huffman coding String literals (names and values) carry a length and a one-bit `H` flag. When set, the octets are encoded with a single canonical Huffman code specified in the RFC, derived from a large sample of real header data - lowercase letters, digits, `/`, `-`, `.` and `:` get short codes; unusual bytes get long ones. Encoders normally emit whichever form is shorter, since Huffman coding random-looking data such as a base64 token can be *longer* than the raw bytes. Typical saving on realistic header text is 20-30%. ## Choosing representations well A good encoder does not index everything. Values that change on every request - a path, a request id, an `Authorization` bearer token, a rotating date - are pure eviction pressure if indexed: they push out stable, high-value entries such as `user-agent` and `cookie`. Real implementations index stable fields, send volatile ones as literals, and use never-indexed for credentials. ## The property that matters later Dynamic table state is *incrementally* mutated by the header blocks themselves, in order. Header block N's meaning depends on every insertion made by blocks 1 through N-1. Over TCP that is automatic because there is exactly one ordered byte stream. Over QUIC, where each stream is ordered independently, this assumption breaks - which is why HTTP/3 needed a redesign rather than a port.

  • Why does each dynamic table entry count an extra 32 bytes toward the size limit?
    The limit is meant to bound memory, not wire bytes. A real implementation stores each entry in some structure with pointers, lengths and list linkage, so a thousand two-byte entries cost far more than 2 KB of RAM. The fixed 32-byte allowance approximates that per-entry overhead so a peer cannot exhaust memory with a flood of tiny entries.
  • Should an encoder index every header field it sends?
    No. Fields whose value changes on every request - request ids, paths, one-time tokens - will never be hit by a later index, and inserting them evicts stable high-value entries such as user-agent and cookie, degrading the compression ratio. Good encoders index stable fields, use literal-without-indexing for volatile ones, and never-indexed for secrets.
  • What happens if the two peers' dynamic tables get out of sync?
    The connection is unrecoverable. An index resolves to the wrong field, so the decoder either produces a corrupt message or fails outright; HPACK errors are treated as a connection error of type COMPRESSION_ERROR, tearing down the whole connection rather than a single stream. That is why header blocks must be processed strictly in order and why HEADERS and CONTINUATION frames for one message cannot be interleaved with another message's.

A shared numbered glossary: the printed section (static) both sides already own, plus a scratch page (dynamic) they append to as new phrases appear, oldest lines crossed off when the page fills.

saying these in an interview costs you the question

  • Describing HPACK as gzip or DEFLATE applied to the header block
  • Thinking the dynamic table is shared between the two directions, or persists across connections
  • Saying the table limit counts entries rather than bytes
  • Claiming Huffman coding always shrinks the value - it can expand random or base64 data
  • Believing every field is inserted into the dynamic table automatically

context

open as a page

HTTP/1.1 sends request headers as plain ASCII text on every single request. What changed in HTTP/2, and why was compressing header fields considered worth the extra complexity?

level: juniorimportance: should knowfreq 40%

basics

~20 s

HTTP/2 encodes headers in binary and compresses them with HPACK. Real requests repeat almost identical headers (cookies, user-agent, accept) worth hundreds of bytes each. On pages making many small requests that overhead dominated; HPACK turns repeats into tiny index references.

open as a page

Early HTTP/2 work (SPDY) compressed header fields with gzip, and that approach was abandoned. What attack made generic compression of HTTP headers unsafe, and how does HPACK handle sensitive header values?

level: seniorimportance: should knowfreq 30%

basics

~20 s

CRIME. When a secret (a session cookie) and attacker-chosen text are compressed together, the compressed size leaks whether they match, letting the attacker guess the secret one character at a time through TLS. HPACK removes substring matching - it indexes whole field values only - and adds a never-indexed representation for secrets.

open as a page

HTTP/3 does not reuse HPACK; it defines QPACK instead. What property of QUIC made HPACK unusable, and what does QPACK do differently to avoid the problem?

level: seniorimportance: should knowfreq 28%

basics

~20 s

HPACK's dynamic table is mutated in strict order, which TCP guarantees but QUIC does not across streams: a header block referencing an entry from a delayed stream would stall everything. QPACK moves table updates onto a dedicated ordered encoder stream and lets the encoder bound how many streams may block.

open as a page