Skip to main content

HTTP/2 Under the Hood: Frames, Streams, and Why Binary Wins

Updated 15 min readMarkdown
Part of seriesComputer Networks: From Packets to the Internet
HTTP/2 Under the Hood: Frames, Streams, and Why Binary Wins

It's been over two months since my last post. I started my internship, and between that and the side projects I'm building, the time I have for this series is a lot smaller than it was. I'm still working through the series, just slower than before.

The last post ended with a promise to go deeper on HTTP/2. Before multiplexing and prioritization make sense, I had to answer a more basic question: what does an HTTP/2 message actually look like on the wire? That led to two things I learned back to back, so this post covers both: frames and HPACK.


1. Why This Topic Matters

In the last post, I said HTTP/2 gives every request a stream ID, and that's what makes multiplexing possible. But I left it abstract. Where does the stream ID live? Who reads it? And if everything is split up, how does the server know what each piece is?

The answer is the frame. Once you understand it, a lot of HTTP/2 stops feeling like separate features, because they are all built on this one small structure.

Frames also raise a second problem. If every request is a HEADERS frame, then the same headers (host, user agent, cookies, accept) are sent again and again on a connection. HTTP/2 fixes the request format, but it would still waste bytes on repeated text. That's what HPACK solves.

2. Source Material

Playlist: Chai aur Code — Computer Networking Series

Video: HTTP/1.1 vs HTTP/2

RFC 9113 — HTTP/2

RFC 7541 — HPACK

HPACK static table (RFC 7541, Appendix A)

Huffman table (background reading)

HTTP 2 frames in depth HPACK explained in depth

3. What I Learned

HTTP/1.1 is a text protocol. A server reading a POST request goes through it line by line. The first line holds the method, path and version. Then come the headers, one per line, each ending in \r\n. A blank line marks the end of the headers, and the body starts right after it. The server already knows the body's size from Content-Length, so it reads exactly that many bytes. It works, but the server is always scanning text for line breaks, and the same plain-text headers are sent again on every request.

HTTP/2 changes the format, not the meaning. Every message is split into frames, and every frame starts with a fixed 9-byte header that says how long the payload is, what type of frame it is, and which stream it belongs to. Since that header never changes shape, the server doesn't search for anything. It reads fixed positions and jumps straight to the payload. A request is made of two main frame types: a HEADERS frame (everything except the body) and a DATA frame (the body). A HEADERS frame always comes first.

The stream ID in each frame is what ties the pieces together. Frames from different requests can be mixed on one connection, and the server uses the stream ID to rebuild each request. The response comes back the same way, in frames carrying the same stream ID as the request.

The payload of a HEADERS frame isn't plain text either. It's compressed with HPACK, which uses three tools together. The static table is a fixed list of 61 common headers. The dynamic table is built during the connection from headers that were actually sent. Huffman coding shrinks the leftover text. The idea underneath is simple: if both sides keep the same table, you can send a number instead of the text.

4. Key Concepts

Binary protocol — data on the wire is raw bytes with a fixed layout, not text meant for humans.

Frame — the smallest unit of HTTP/2: a 9-byte header followed by a payload.

HEADERS frame (type 0x01) — carries the request or response headers, HPACK encoded.

DATA frame (type 0x00) — carries the body.

Length (24 bits) — payload size, so the server knows how many bytes to read next.

Flags (8 bits) — small extra instructions, like END_STREAM (last frame of this stream) or END_HEADERS (headers are complete).

Stream ID (31 bits) — which request/response this frame belongs to.

HPACK — HTTP/2's header compression, applied only to the HEADERS frame payload.

Static table — 61 predefined header entries, the same on every client and server, never changing.

Dynamic table — a table built during the connection from headers actually sent. Its entries start at index 62.

Index — the number of an entry in the tables. 1 to 61 is static, 62 and up is dynamic.

Literal — a header sent as actual text, because it isn't fully available in a table.

Huffman coding — optional string compression using a fixed table: common characters get shorter codes.

H bit — one bit in front of a string's length. 1 means the string is Huffman coded.

5. How It Works

The frame header

Every frame starts with this fixed layout:

+-----------------------------------------------+
| Length (24 bits)                              |
+---------------+---------------+---------------+
| Type (8)      | Flags (8)                     |
+-+-------------+-------------------------------+
|R| Stream Identifier (31 bits)                 |
+-+---------------------------------------------+
| Frame Payload (...)                           |
+-----------------------------------------------+

24 + 8 + 8 + 1 + 31 = 72 bits, which is 9 bytes. The R bit is reserved and always 0.

How the server reads one frame

1. Read 24 bits  → Length: the payload is N bytes
2. Read 8 bits   → Type: 0x01 = HEADERS, 0x00 = DATA
3. Read 8 bits   → Flags
4. Read 1 bit    → Reserved (ignored)
5. Read 31 bits  → Stream ID: which request is this?
6. Read N bytes  → Payload, then move on to the next frame

If the type is HEADERS, the server knows the payload is HPACK-encoded and decodes it. If it's DATA, the payload is the body as is. No searching, no guessing.

A real DATA frame carrying 5 bytes on stream 1, and it's the last frame of that stream:

00 00 05   00     01          00 00 00 01   [5 bytes of body]
Length=5   DATA   END_STREAM  Stream ID=1

Compare that with HTTP/1.1, where finding the end of the headers means walking through text until you hit a blank line.

Three requests, one connection

Say the client sends three requests:

# Request Frames
1 POST with a body HEADERS + DATA
2 GET HEADERS only
3 GET for an HTML file HEADERS only

Requests 2 and 3 have no body, so each is a single frame. All frames are sent independently and can be mixed on the same TCP connection:

[HEADERS s1] [HEADERS s3] [DATA s1] [HEADERS s5]

The server uses the stream ID to put them back together. Every frame of request 1 carries stream ID 1, so it collects the HEADERS and DATA frames of stream 1 and rebuilds the full request, even though other requests' frames arrived in between.

The response goes the other way with the same stream ID:

Request   →  HEADERS (s1) + DATA (s1)
Response  ←  HEADERS (s1: status, headers) + DATA (s1: body)

Where HPACK fits

HTTP/2 frame
 ├── 9-byte frame header (Length, Type = HEADERS, Flags, Stream ID)  ← not compressed
 └── payload                                                         ← HPACK-encoded header block

HPACK only touches headers, in both requests and responses. DATA frames aren't compressed by it, and the frame header stays uncompressed so the server can read it in a fixed format.

HPACK tool 1: the static table

A fixed list of 61 common headers. A few of them:

Index Name Value
1 :authority (empty)
2 :method GET
3 :method POST
4 :path /
5 :path /index.html
6 :scheme http
7 :scheme https
8 :status 200
24 cache-control (empty)
58 user-agent (empty)

There are two kinds of entries:

  • Name and value fixed (:method: GET): the whole header becomes a single index. Nothing else is sent.
  • Name only, value empty (:authority, user-agent): the name is known, but the value changes every time, so the value still has to be sent as a literal.

HPACK tool 2: Huffman coding

For those literal values, HPACK can compress the text with Huffman coding. The table is fixed and predefined. It isn't learned from earlier requests. Common characters get short codes and rare ones get long codes. For example, www.example.com is 15 bytes as text and 12 bytes after Huffman.

It's optional per string. The H bit in front of the length says whether the string is Huffman coded. Huffman output rarely ends on a byte boundary, so it's padded with 1-bits up to the next byte. Those 1s are the start of the special "end of string" code (all 1s), so the padding can never be mistaken for a real character.

HPACK tool 3: the dynamic table

The static table can't hold things like www.example.com, because that's different for every site. So a second table is built during the connection.

  • It starts empty when the connection starts.
  • When a new header is sent in full, it can be added to the table with index 62. Older entries shift up by one.
  • The next time the same header appears, only the index is sent.
  • It has a size limit. When it's full, the oldest entries are dropped first. It's a FIFO queue.
  • Client and server each keep their own copy. They never send the table to each other. Both build the same table by reading the same header blocks in the same order.
  • It only lives for one connection. A new connection starts with an empty table.

How the receiver knows what it's reading

The first bits of each entry say what kind it is:

First bits Type Meaning
1xxxxxxx Indexed header field Whole header is in a table. Remaining 7 bits = index
01xxxxxx Literal with incremental indexing Send it and add it to the dynamic table. Remaining 6 bits = name index
0000xxxx Literal without indexing Send it, don't add it (value changes every request)
0001xxxx Literal never indexed Send it, never add it, and proxies must not add it either (sensitive values)

A literal whose name is already in a table:

01 + name index (6 bits)
H bit + value length (7 bits)
value string

A literal whose name is new:

01 000000            (index 0 = new name)
H bit + name length (7 bits)
name string
H bit + value length (7 bits)
value string

The "never indexed" type exists for security. If secret values like tokens went into a shared compression table, an attacker could guess them by watching how the compressed size changes. 0001 keeps sensitive headers out of every table. 0000 is only about not wasting table space.

Worked example

This is the example from RFC 7541 (Appendix C), so every byte can be checked against the spec. The client sends two requests to www.example.com.

Request 1: GET http://www.example.com/

82                        1 0000010  → index 2 → :method: GET
86                        1 0000110  → index 6 → :scheme: http
84                        1 0000100  → index 4 → :path: /
41                        01 000001  → literal, add to table, name = index 1 (:authority)
8c                        1 0001100  → H = 1 (Huffman), length = 12 bytes
f1e3c2e5f23a6ba0ab90f4ff            → Huffman("www.example.com")

That's 17 bytes, against about 60 characters of plain text. The dynamic table now holds:

[62]  :authority: www.example.com

Request 2: the same request plus cache-control: no-cache

82                        → :method: GET           (static, index 2)
86                        → :scheme: http          (static, index 6)
84                        → :path: /               (static, index 4)
be                        1 0111110  → index 62 → :authority: www.example.com (dynamic table)
58                        01 011000  → literal, add to table, name = index 24 (cache-control)
86                        1 0000110  → H = 1, length = 6 bytes
a8eb10649cbf                        → Huffman("no-cache")

That's 12 bytes, for a request with more headers than the first one. :authority cost 14 bytes in request 1 (41, 8c and 12 bytes of Huffman) and 1 byte in request 2 (be). The dynamic table now holds:

[62]  cache-control: no-cache
[63]  :authority: www.example.com    ← shifted from 62 to 63 when the new entry was added

6. Things That Confused Me

The frame header size. My first notes said "4 bytes" and "9 bits". The header is 9 bytes (72 bits). I mixed up bits and bytes. Adding the fields (24 + 8 + 8 + 1 + 31 = 72) is the easiest way to remember it.

Where did the request line go? HTTP/1.1 has POST /api HTTP/1.1 as the first line. HTTP/2 has no text line. That information goes into the HEADERS frame as pseudo-headers (:method, :path, :scheme, :authority), inside the HPACK payload. Same information, different format.

"Independent" doesn't mean "unordered." Frames from different streams can be mixed freely, but frames inside the same stream still arrive in order. The stream ID is what lets the server keep streams apart.

One request isn't always two frames. A large body can be split across several DATA frames, because there's a maximum frame size (16,384 bytes by default). The idea stays the same: the Length field says how much to read.

Static table doesn't mean "the whole header is in it." Some entries have a fixed value (:method: GET), but some only have a name (:authority). For those, the name is an index and the value is still a literal.

Huffman isn't built from earlier requests. I first thought it worked like the dynamic table, learning from history. It doesn't. Its table is fixed. The dynamic table is the part that learns.

FIFO, not FILO. I first wrote that the dynamic table works like a stack. It's a queue: new entries go in at the front, and the oldest ones are removed first when it's full.

The index bits. I wrote 1 000010 for index 2, but there are 7 bits after the leading 1: 1 0000010, which is 0x82. Count the bits when you decode by hand.

How do both sides end up with the same table? They never exchange it. Each side applies the same instructions to its own table, in the same order, so the tables stay identical.

7. My Explanation in Simple Words

HTTP/1.1 is a long letter. To find where the address ends and the message begins, you read line by line until you hit a blank line.

HTTP/2 is more like parcels with a printed label in the same spot on every box. The label says how heavy the box is, what type it is, and which order it belongs to. The warehouse worker never opens the box to find that out. They read the label, put the box on the right pile, and move on. The stream ID is the order number on the label: boxes from many orders can ride the same conveyor belt, and each one still ends up in the right pile.

HPACK is about the paperwork inside each box. Two people on a long phone call keep needing the same long phrases. Before the call, they get the same printed phrasebook with 61 common phrases, each with a number. That's the static table. During the call, they also keep a shared notebook. The first time you say something new in full, both of you write it down with the next number, and from then on you just say the number. That's the dynamic table. The notebook has limited pages, so when it's full the oldest line gets crossed out first.

When you do have to say something in full, you use shorthand where common letters take less space, like Morse code gives e a single dot. That's Huffman. And for secrets, like a password, you tell the other person not to write it in the notebook. That's the never-indexed type.

8. Key Takeaways

  • HTTP/1.1 is text. The server scans for \r\n and uses Content-Length to find where the body ends. HTTP/2 is binary: everything is a frame with a fixed 9-byte header (Length, Type, Flags, Reserved, Stream ID).
  • Type tells the server what the frame is (0x01 HEADERS, 0x00 DATA). Stream ID tells it which request the frame belongs to. A HEADERS frame always comes first.
  • HPACK compresses only the HEADERS frame payload. The frame header and DATA frames are left alone.
  • HPACK uses a static table (61 fixed entries), a dynamic table (built per connection, starts at index 62, oldest entries dropped first) and optional Huffman coding (a fixed table, shorter codes for common characters).
  • The first bits of each entry say how to read it: 1 indexed, 01 literal and add to table, 0000 literal and don't add, 0001 literal and never add.
  • Client and server never exchange their dynamic tables. They stay in sync by reading the same header blocks in the same order.
  • In the RFC example, a repeated :authority header goes from 14 bytes to 1 byte. The saving builds up over many requests, and the first request on every new connection is the most expensive.
  • HTTP/2 still runs on a single TCP connection. If TCP loses a packet, every stream on that connection waits. So HTTP/2 fixes head-of-line blocking at the HTTP level, but not at the TCP level.

9. Next Topic

Next up: the rest of what I promised in the last post, HTTP/2 head of line blocking and QUIC, which exist because of the TCP-level blocking HTTP/2 couldn't fix.

About the author

  • I’m a curious learner and aspiring software developer who enjoys building real-world systems, exploring new technologies, and learning in public. I write about coding, personal growth, and turning ideas into practical systems—while simplifying complex concepts for others.

No comments yet. Be the first to comment.

Computer Networks: From Packets to the Internet

Part 1 of 10

A weekly learning series where I document my journey of understanding computer networks from scratch. From packets, IP addresses, and DNS to TCP, HTTPS, CDNs, and modern internet architecture, each article focuses on building intuition through practical examples and real-world applications in backend and distributed systems.

Up nextHow HTTP Works: What Happens After DNS Hands You an IP1. Why This Topic Matters The last post ended on a promise: once DNS resolves a name to an IP, what actually happens next? Every post in this series up to now — TCP, UDP, and finally DNS — has been ab