Intuition What this page is
The parent note told you the rules of HTTP/2. This page makes you do the arithmetic — encode a real integer bit by bit, count the exact bytes HPACK saves, walk a stream ID assignment, and catch the traps (zero, overflow, even/odd IDs, cache hits). If you can work every cell of the matrix below, you understand the wire, not just the buzzwords.
Every symbol we use is built from zero. A byte = 8 bits. A bit is a single 0 or 1. When we write 0b01010 the 0b just means "read the following as binary digits". That is all the notation you need to start.
Definition Units used on this page: KiB vs KB
To avoid the classic binary-vs-decimal muddle, this page uses KiB (kibibyte) = 1024 bytes, exactly a power of two, because networking byte counts and windows are naturally powers of two. We never write "KB" ambiguously. When you see "5 KiB" it means exactly 5 × 1024 = 5120 bytes.
Definition Prefix, prefix length
N , and the HPACK first byte
HPACK never sends a raw multi-byte integer. It packs a small integer into the low N bits of a byte that also carries flag bits in its high bits . Those flag bits already say what kind of field this is (indexed? literal? etc.). So the integer can only use the leftover N bits — this is the prefix , and N is the prefix length .
A "5-bit prefix" means: the top 8 − 5 = 3 bits of that byte are flag bits, the bottom 5 bits hold (the start of) the integer.
A "6-bit prefix" means: the top 8 − 6 = 2 bits are flag bits, the bottom 6 bits hold the integer.
A "7-bit prefix" means: 1 flag bit on top, 7 bits for the integer.
Definition HPACK field types and their flag-bit patterns (which
N each uses)
Every HPACK header field starts with a first byte whose high flag bits pick the representation , and that choice fixes the prefix length N . The four types you meet on this page:
Representation
High flag bits
Prefix length N
Meaning
Indexed
1
7-bit
whole header = one table index
Literal with incremental indexing
01
6-bit
new header, add it to the dynamic table
Literal without indexing
0000
4-bit
new header, do not store
Dynamic table size update
001
5-bit
resize the dynamic table
So when Example 5 writes "01 flag + a 6-bit name-index", the 01 is the literal-with-incremental-indexing marker, and because that marker eats the top 2 bits, exactly N = 6 bits are left for the name index. The mapping of flag bits → N is not arbitrary — it is fixed by the RFC 7541 spec so encoder and decoder always agree.
Definition The integer rule (this is what "prefix integer encoding" means)
To encode a non-negative integer I in an N -bit prefix:
If I < 2 N − 1 : put I directly into the N prefix bits. Done (one byte).
Otherwise: put 2 N − 1 (all prefix bits = 1) into the prefix as an overflow marker , then emit I ′ = I − ( 2 N − 1 ) as continuation bytes in 7-bit groups (high bit set = "more follow").
I is always ≥ 0 — see the [!mistake] on invalid inputs below for why negatives and unbounded lengths are rejected.
Common mistake "Any integer, including negatives or arbitrarily large ones, can be prefix-encoded."
Why it feels right: the continuation scheme looks like it can grow forever. Fix: HPACK integers are non-negative only — there is no sign bit anywhere in the format, so a negative I is simply not representable (an encoder that tried would produce garbage). And decoders must cap the number of continuation bytes (RFC 7541 requires rejecting integers that would exceed the implementation's limit, typically a value that no longer fits a defined field) to stop a malicious peer sending an endless run of high-bit-set bytes — a denial-of-service. So the valid domain is 0 ≤ I ≤ I m a x , and malformed inputs (negative, or an over-long continuation run) are connection errors , not numbers.
Every case class this topic can throw at you, and which worked example covers it:
Cell
Case class
Covered by
A
Prefix integer — small value, fits in the prefix
Example 1
B
Prefix integer — value overflows into continuation bytes
Example 2
C
Boundary value I = 2 N − 1 (the "off by one" trap)
Example 3
D
Zero / degenerate input (I = 0 , empty header)
Example 4
E
HPACK byte-savings count over repeated requests
Example 5
F
Stream ID assignment — odd vs even, ordering
Example 6
G
Multiplexing interleave + TCP HOL limiting case
Example 7
H
Server push cache-hit vs wasted-push (real-world)
Example 8
I
Exam twist — decode raw bytes back to a number
Example 9
J
Invalid / limiting — negative or over-long integer
See [!mistake] above
Worked example Encode index
I = 10 in a 5-bit prefix
Forecast: before reading on, guess — will this need one byte or several? What is the largest number a 5-bit prefix can hold directly ?
Steps:
Compute the prefix limit 2 N − 1 with N = 5 : 2 5 − 1 = 31 .
Why this step? The rule says: if I < 2 N − 1 we store I directly in the N bits. So we must first know "the ceiling of the prefix". 31 is the biggest value the 5 bits can mean without triggering an overflow.
Compare: is 10 < 31 ? Yes.
Why this step? This decides the whole branch. Because 10 is safely below 31 , we never touch continuation bytes.
Write 10 in binary in the 5 low bits: 10 = 8 + 2 = 0101 0 2 . The full byte, with flag bits shown as 000, is 000 01010.
Why this step? The 5-bit field literally holds the number. The 3 high bits are the HPACK flag bits — here we display them as 000 so you see the whole byte. (For an "indexed" field the top flag bit would actually be 1; the point is those bits belong to the representation type , never to the integer.)
Answer: one byte; prefix bits 01010, full byte shown as 000 01010.
Verify: read 01010 back as a plain binary number: 0 ⋅ 16 + 1 ⋅ 8 + 0 ⋅ 4 + 1 ⋅ 2 + 0 ⋅ 1 = 10 . ✓ Matches the input. Units check: an index is a pure count, no dimension — good.
Worked example Encode index
I = 1337 in a 5-bit prefix
Forecast: 1337 is far bigger than 31 . Guess how many bytes you'll need, and what value the first byte carries.
Steps:
2 5 − 1 = 31 . Is 1337 < 31 ? No.
Why this step? We fall into the overflow branch. The prefix can't hold the number, so its 5 bits become all 1s (the marker), i.e. the first byte is 000 11111.
First byte prefix = 31 . Then compute the leftover I ′ = I − 31 = 1337 − 31 = 1306 .
Why this step? The prefix already "spent" the value 31 , so the continuation bytes only need to carry what's left over . This avoids double-counting.
Emit I ′ in 7-bit groups , high bit = "more bytes follow":
1306 ≥ 128 ? Yes. 1306 mod 128 = 26 . Emit 26 + 128 = 154 . Set I ′ ← ⌊ 1306/128 ⌋ = 10 .
10 ≥ 128 ? No. Emit 10 (high bit clear — this is the last byte).
Why this step? Each continuation byte packs 7 real bits (0–127) plus 1 flag bit (the 128). Splitting into 7-bit chunks is exactly varint encoding — the same trick protobuf and QUIC use. The high bit is a self-describing "continue?" flag so the decoder never needs a length field.
Answer: bytes 31, 154, 10 (first byte's full form 000 11111).
Verify: decode: 31 + ( 154 − 128 ) + 10 ⋅ 128 = 31 + 26 + 1280 = 1337 . ✓ Reconstructs the input.
Worked example Encode index
I = 31 in a 5-bit prefix (the classic off-by-one)
Forecast: 31 = 2 5 − 1 . It "looks like" it fits in 5 bits. Does it? Guess before step 1.
Steps:
The rule uses a strict inequality: store directly only if I < 2 N − 1 . Here I = 31 and 2 N − 1 = 31 , so I < 31 is false .
Why this step? This is the trap. If we naively stored 31 in the prefix, the decoder would read 11111 and think "prefix is full, read continuation bytes" — and then wait forever for bytes that never came. The value 2 N − 1 is reserved as the "overflow marker", so a genuine 31 must also overflow.
First byte prefix = 31 (marker, full byte 000 11111). Leftover I ′ = 31 − 31 = 0 .
Why this step? We are exactly at the ceiling, so nothing is left over.
Emit I ′ = 0 : is 0 ≥ 128 ? No. Emit a single byte 0 (high bit clear = last).
Why this step? One continuation byte carrying 0 tells the decoder "the overflow added nothing".
Answer: bytes 31, 0 (two bytes for a value that "should" fit in 5 bits!).
Verify: decode: 31 + 0 = 31 . ✓ And note it is not ambiguous with I = 31 + 128 = 159 , which would encode as 31, 128, 1. The reserved marker keeps every value uniquely decodable.
I = 0 in a 5-bit prefix, and encode an empty header value
Forecast: what is the smallest possible encoding? Can a header value be zero characters long?
Steps:
I = 0 : is 0 < 31 ? Yes → store directly. Prefix bits 00000, full byte 000 00000.
Why this step? Zero is a perfectly valid index/count; it fits trivially. This is the smallest case — the base of the whole system. (It also confirms I ≥ 0 : zero is the floor of the valid domain.)
An empty header value (a header whose value is the empty string, e.g. a bare flag header): its length is 0 . HPACK writes a length prefix of 0 and then no value bytes.
Why this step? Length-prefixing means "read exactly L bytes". With L = 0 you read nothing. This is why HPACK never needs a terminator character — length 0 is unambiguous.
Answer: I = 0 → byte 000 00000 (=0); empty value → single length byte 0, zero value bytes.
Verify: decode 00000 = 0 . ✓ Decode length 0 ⇒ read 0 bytes ⇒ empty string. ✓ Degenerate case is well-defined, no crash.
cookie header whose value is 200 bytes, sent on 100 requests — exact bytes, HPACK vs raw?
Forecast: raw HTTP/1.1 resends the full cookie every time. Guess the total, then guess HPACK's total. The ratio will surprise you.
Let the cookie value be V = 200 bytes, over R = 100 requests. We now count every overhead byte, not an estimate.
Steps:
Raw value bytes per send: we count only the cookie's value bytes V = 200 for a fair comparison (both schemes must carry those). Raw HTTP/1.1 resends them every time:
raw = V × R = 200 × 100 = 20000 bytes .
Why this step? HTTP/1.1 headers are plain text resent verbatim — there is no memory between requests, so each of the 100 requests pays the full 200 bytes.
HPACK request 1 (literal with incremental indexing) — count every field:
1 byte: the first byte. Its top 2 bits are the 01 flag (this is the literal-with-incremental-indexing representation — see the field-types table above), which leaves a 6-bit prefix for the name index. The static-table cookie name lives at index 32, and 32 < 2 6 − 1 = 63 , so index 32 fits directly in those 6 bits → 1 byte .
1 byte: the value length prefix — a 7-bit prefix integer holding 200 . Since 200 ≥ 2 7 − 1 = 127 , it actually needs 2 bytes: marker 127 , then 200 − 127 = 73 . So 2 bytes for the length.
200 bytes: the literal value itself (assume no Huffman shrink, worst case).
Total request 1 = 1 + 2 + 200 = 203 bytes.
Why this step? HPACK must transmit the actual bytes the first time — you can't reference something the decoder hasn't seen. We now include the name-index byte (whose 6-bit prefix comes straight from the 01 field-type marker) and the (2-byte) value-length prefix that a naive estimate ignores. Sending it once teaches both sides its dynamic-table index (index 62).
HPACK requests 2..100 (indexed): each references index 62 using the indexed representation, whose single top flag bit is 1 and leaves a 7-bit prefix. Since 62 < 2 7 − 1 = 127 , that's a single byte. So 1 byte each, 99 times:
hpack = 203 + 99 × 1 = 302 bytes .
Why this step? After the table entry exists, the whole header collapses to one index byte. This is the payoff of the dynamic table, now counted exactly including request 1's real overhead.
Savings ratio:
hpack raw = 302 20000 ≈ 66.2 × smaller .
Why this step? The two totals in isolation don't tell you how much HPACK matters — a ratio does, and that ratio is the whole learning objective of this example: it turns "compression helps" into a concrete "66× fewer bytes on the wire for this header". Dividing raw by HPACK gives a dimensionless multiplier (bytes ÷ bytes), the standard way to express a compression factor, so you can compare it against gzip claims or HTTP-1.1 's zero-compression baseline.
Answer: raw = 20000 B; HPACK = 302 B (including all overhead); ratio ≈ 66.2 × .
Verify: 200 × 100 = 20000 ; request 1 = 1 + 2 + 200 = 203 ; total = 203 + 99 = 302 ; 20000/302 = 66.22 …
Why this step? We re-do the arithmetic independently of the steps to catch a slip — and we re-derive the 2-byte length prefix (200 = 127 + 73 , 73 < 128 ⇒ single continuation byte) so the "203" isn't taken on faith. Units are bytes throughout, dimensionally consistent.
Worked example A client makes 3 requests; the server pushes 2 resources. List every Stream ID.
Forecast: the parent note said client IDs are odd , server-push IDs are even . Guess the exact IDs before reading.
Steps:
Client-initiated streams use odd IDs, starting at 1, increasing: request 1 → 1 , request 2 → 3 , request 3 → 5 .
Why this step? Odd IDs are reserved for the client so the two sides never pick the same number. Client counts 1 , 3 , 5 , … — skip evens entirely.
Server-pushed streams use even IDs, starting at 2: first push → 2 , second push → 4 .
Why this step? Evens belong to the server. Because the two sets (odds, evens) are disjoint, there is no way a client stream and a pushed stream collide, even though they share one connection.
Stream ID 0 is reserved for connection-level frames (like SETTINGS, WINDOW_UPDATE that apply to the whole connection, not a single stream).
Why this step? Covers the degenerate ID: 0 is neither odd-client nor even-push — it's the "everybody" channel.
Answer: client streams 1, 3, 5; pushed streams 2, 4; connection-level control channel 0.
Verify: odds and evens are disjoint sets, so { 1 , 3 , 5 } ∩ { 2 , 4 } = ∅ ✓ (no collision). Every ID is < 2 31 − 1 (the 31-bit Stream ID field limit): max = 5 ≪ 2 31 − 1 ✓. All client IDs are odd (1 , 3 , 5 ) and all push IDs even (2 , 4 ) ✓. Units check: a Stream ID is a pure integer label, no dimension — good.
Intuition Figure key (read this even if the image won't load)
The figure draws one horizontal line = a single TCP connection, with bytes flowing left → right, in order . Sitting on that line are six rounded boxes (frames) in send order: coral boxes are DATA frames of stream 1 , mint boxes are DATA frames of stream 3 . The third box is grey with a red "X" — a lost TCP segment . A coral arrow spans from just after the lost box to the right edge, labelled "all later bytes (both streams) STALL until retransmit = TCP HOL". A small two-box legend at the bottom maps coral→stream 1, mint→stream 3. Narrative: frames of the two streams interleave freely (coral, mint, coral, mint), but once a segment is dropped, every box to its right — coral and mint — is frozen until TCP redelivers the missing bytes.
[!example] Two responses share one connection; a packet is lost. What actually blocks?
Forecast: HTTP/2 "removes head-of-line blocking". So a lost packet on stream 1 shouldn't block stream 3... right? Guess, then check.
Steps:
Normal case (no loss): frames interleave freely: DATA(1), DATA(3), DATA(1), DATA(3). Each side sorts by Stream ID. Stream 3 finishes even if stream 1 is huge and slow.
Why this step? At the HTTP layer , streams are independent — this is exactly the multiplexing win. A slow stream 1 no longer blocks stream 3. (Contrast HTTP-1.1 , where response 1 must fully finish first.)
Loss case (limiting scenario): the grey red-X box in the figure is a TCP segment that got dropped. TCP must deliver bytes in order , so it holds back every later byte — including the mint stream-3 boxes — until the lost segment is retransmitted.
Why this step? This is the trap the parent flagged. HTTP/2 removed application -layer HOL but not transport -layer HOL, because one TCP connection has one ordered byte stream. See TCP for why in-order delivery forces this.
The fix: HTTP-3-and-QUIC runs over UDP and gives each stream its own ordering, so a lost packet only stalls its own stream.
Why this step? Closes the case: the only way to truly kill transport HOL is to change transports.
Answer: no loss → full independence; one lost TCP segment → all streams stall (TCP HOL); QUIC removes this.
Verify: logically, HTTP/2 streams over 1 TCP conn = 1 ordered byte stream, so a gap blocks all subsequent bytes. Consistent with the parent's [!mistake] callout. ✓
Worked example Server pushes
style.css (5 KiB). Case (i): browser had no cache. Case (ii): browser already cached it. Net bytes?
Forecast: push saves one round trip. But is it always a win? Guess the outcome of case (ii).
Let the CSS be S = 5 KiB = 5 × 1024 = 5120 bytes, and one round-trip time cost a delay we call RTT .
Steps:
Case (i) — cold cache: server pushes 5 KiB the browser needed anyway, and saves one RTT (no separate request needed). Net data = 5120 bytes (would have been fetched regardless), minus one round trip of latency.
Why this step? Here push does its job: the resource arrives before the browser's HTML parser even asks for it. This is the intended win.
Case (ii) — warm cache: the browser already has style.css. The server, not knowing this, still pushes all 5120 bytes. Wasted data = 5120 bytes, and worse — those bytes competed for the shared congestion window with the actual HTML the browser urgently needed (see TCP ).
Why this step? This is the real-world footgun. Push has no reliable "do you already have this?" negotiation, so it gambles. Net effect can be slower page loads.
Modern replacement: 103 Early Hints + <link rel=preload> tells the browser what to fetch , letting the browser decide (respecting its own cache) — no wasted bytes.
Why this step? Explains why Chrome deprecated push: the cache-aware pull beats the blind push.
Answer: cold cache saves 1 RTT for 5120 bytes well spent; warm cache wastes 5120 bytes and can hurt. Push is not always faster.
Verify: wasted bytes in case (ii) = 5 × 1024 = 5120 > 0 ✓ — a strict loss, confirming "push always faster" is false.
Worked example You receive the bytes
31, 128, 1 from a 5-bit prefix integer field. What integer I is this?
Forecast: the first byte's prefix is 31 = 2 5 − 1 , the overflow marker. So more bytes follow. Reconstruct I .
Steps:
First byte prefix = 31 = 2 5 − 1 → this is the "prefix full" marker. Start the running total at 31 .
Why this step? Decoding is the mirror of encoding: seeing the marker tells us to read continuation bytes and add their contribution on top of 31 . (We ignore this byte's 3 high flag bits — they belong to the representation type, not the integer.)
Read continuation bytes, each contributing ( byte mod 128 ) times a place value 12 8 k , where k counts continuation bytes from 0. The high bit (128) means "more follow":
Byte 128 : high bit set → more follow. Value = 128 − 128 = 0 , place 12 8 0 = 1 . Contribution 0 × 1 = 0 .
Byte 1 : high bit not set → last byte. Value = 1 , place 12 8 1 = 128 . Contribution 1 × 128 = 128 .
Why this step? This is varint decoding: little-endian 7-bit groups. The place value grows by 128 each byte because each group holds 7 bits (2 7 = 128 ).
Sum: I = 31 + 0 + 128 = 159 .
Why this step? Marker plus the reconstructed leftover gives the original integer.
Answer: I = 159 .
Verify: re-encode 159 : 2 5 − 1 = 31 , leftover 159 − 31 = 128 . 128 ≥ 128 : emit ( 128 mod 128 ) + 128 = 0 + 128 = 128 , then ⌊ 128/128 ⌋ = 1 ; 1 < 128 emit 1 . Bytes 31, 128, 1. ✓ Round-trips exactly.
Why this step? Encoding and decoding must be exact inverses; re-encoding the decoded answer and matching the original bytes is the strongest sanity check.
Recall Self-test — cover the answers
What structurally is a "5-bit prefix"? ::: The low 5 bits of a byte hold (the start of) the integer; the top 3 bits are flag bits set by the representation type.
How many flag bits and prefix bits does the "literal with incremental indexing" (01) representation use? ::: 2 flag bits (01) on top, a 6-bit prefix for the name index.
What is 2 N − 1 for a 5-bit prefix, and why is that value special? ::: 31 ; it is the reserved overflow marker, so any I ≥ 31 (including 31 itself) must use continuation bytes.
Encode I = 10 in a 5-bit prefix (show the full byte). ::: One byte 000 01010.
Encode I = 31 in a 5-bit prefix. ::: Two bytes: 31, 0.
Decode bytes 31, 154, 10 (5-bit prefix). ::: 31 + 26 + 1280 = 1337 .
Can I be negative? ::: No — HPACK integers are non-negative only; there is no sign bit, and over-long continuation runs are rejected as errors.
Which Stream IDs does a client use, and which does server push use? ::: Client = odd (1,3,5,…); server push = even (2,4,…); 0 = connection-level.
Does HTTP/2 multiplexing remove TCP-level head-of-line blocking? ::: No — only application-layer HOL. A lost TCP segment still stalls all streams; QUIC fixes this.
When does server push hurt ? ::: When it pushes an already-cached resource, wasting bytes and stealing congestion-window capacity from the real HTML.
Mnemonic "Marker means more"
When the prefix bits are all 1s (= 2 N − 1 ), the number did not fit — read on. Continuation bytes: high bit set = more coming , high bit clear = you're done. Same rule for encoding and decoding.
See also: Huffman-Coding (how HPACK compresses literal strings), Varint-encoding (the 7-bit continuation trick), CRIME-and-BREACH-attacks (why HPACK avoids naive gzip), TLS and TCP (the handshake and ordering costs), HTTP-3-and-QUIC (the transport-HOL fix).