DevLift
Back to Blog

How HTTP/2 Works Under the Hood

HTTP/2 replaces ASCII text with a binary framing layer and multiplexes dozens of requests over one TCP connection. Here's what actually happens at the wire level.

Admin
March 26, 202610 min read2 views

How HTTP/2 Works Under the Hood

You've probably seen the h2 label in Chrome DevTools' Network tab, or noticed that modern sites load dozens of resources in parallel. HTTP/2 is why that's fast. But unlike HTTP/1.1 — where you can literally telnet to a server and type headers — HTTP/2 is a binary protocol with a completely different mental model. Let's open it up.

The Problem HTTP/2 Was Solving

HTTP/1.1 is a text protocol. Each request looks like:

GET /style.css HTTP/1.1\r\n
Host: example.com\r\n
\r\n

This simplicity has a cost. HTTP/1.1 does define pipelining, so in theory you can put several requests on one connection — but responses must come back in request order, so one slow response stalls everything queued behind it. That's head-of-line blocking, and it's why every major browser gave up on pipelining and instead opens 6 parallel TCP connections per host. Each connection carries its own TLS handshake, its own congestion window, and its own OS socket. Expensive, and it doesn't fix the blocking, it just gives you six independent places for it to happen.

A note on version numbers before we go further, because it matters for a couple of claims below: HTTP/2 is now specified by RFC 9113 (June 2022), which obsoletes RFC 7540. Where they differ, 9113 wins.

HTTP/2 fixes this at the protocol level by redesigning the wire format from scratch.

The Mental Model: One Connection, Many Streams

The key insight is multiplexing: HTTP/2 sends multiple requests and responses simultaneously over a single TCP connection, interleaved at the frame level.

Rendering diagram...

No request waits for another to complete. Streams are independent. A slow database query on stream 1 does not block the CSS delivery on stream 3.

Layer 1: Binary Framing

Everything in HTTP/2 is a frame. Every frame starts with the same 9-octet header. RFC 9113 §4.1 drops the old bit-diagram in favour of a field list, which is easier to read anyway:

HTTP Frame {
  Length (24),
  Type (8),
  Flags (8),
  Reserved (1),
  Stream Identifier (31),
  Frame Payload (..),
}
  • Length: payload size in octets, not counting the 9-octet header. 24 bits allows ~16MB, but RFC 9113 forbids sending more than 2^14 (16,384) unless the peer raised SETTINGS_MAX_FRAME_SIZE.
  • Type: what kind of frame this is (DATA, HEADERS, SETTINGS, etc.)
  • Flags: type-specific bits, e.g. END_STREAM, END_HEADERS, PADDED
  • Reserved: one bit, must be left unset
  • Stream ID: 31-bit integer identifying which stream this frame belongs to. 0 means connection-level.

There are 10 frame types defined in the spec:

TypeIDPurpose
DATA0x0Request/response body bytes
HEADERS0x1Header fields (HPACK-encoded)
PRIORITY0x2Stream dependency weight — deprecated by RFC 9113 §5.3.2
RST_STREAM0x3Immediately terminate a stream
SETTINGS0x4Negotiate connection parameters
PUSH_PROMISE0x5Server push announcement
PING0x6Measure RTT, keep-alive
GOAWAY0x7Graceful connection shutdown
WINDOW_UPDATE0x8Flow control credit
CONTINUATION0x9Overflow for HEADERS frames

The PRIORITY row needs the citation to be right, because the two RFCs get mixed up constantly. RFC 9113 is what deprecates it — "The PRIORITY frame (type=0x02) is deprecated; see Section 5.3.2." The replacement scheme, based on urgency and incremental parameters carried in a header field, is RFC 9218. Deprecated by one, superseded by the other.

Layer 2: Streams and Stream States

A stream is a bidirectional sequence of frames within a connection. Each stream has an ID:

  • Client-initiated streams: odd numbers (1, 3, 5, ...)
  • Server-initiated streams (push): even numbers (2, 4, 6, ...)
  • Stream 0: reserved for connection-level frames (SETTINGS, PING, GOAWAY)

Streams follow a lifecycle:

Rendering diagram...

Once a stream is closed, its ID can never be reused on that connection. Stream IDs are 31 bits and clients must use odd numbers, which leaves about 2^30 — a billion, not two — client-initiated streams per connection. That still sounds unlimited, and RFC 9113 explicitly says it isn't: "Stream identifiers cannot be reused. Long-lived connections can result in an endpoint exhausting the available range of stream identifiers." A client that runs out has to open a new connection, and a proxy holding one connection open for months at high request rates is exactly the shape of workload where that becomes real.

A typical request-response looks like this at the frame level:

Client → HEADERS (stream 1, END_HEADERS)
         [HPACK-encoded: GET /api/users, Host: api.example.com, ...]

Server → HEADERS (stream 1, END_HEADERS)
         [HPACK-encoded: 200 OK, content-type: application/json, ...]
Server → DATA (stream 1)
         [{"users": [...]}]
Server → DATA (stream 1, END_STREAM)
         []  ← empty data frame to signal end

The END_STREAM flag on the last DATA frame transitions the server's side to half-closed. When the client receives it, the stream closes.

Layer 3: HPACK Header Compression

HTTP headers are repetitive. Every request sends User-Agent, Accept-Encoding, Authorization, and a dozen others — the same values, over and over. In HTTP/1.1, they're sent verbatim as ASCII every time.

HPACK (RFC 7541) eliminates this redundancy using three mechanisms:

1. Static Table

A predefined table of 61 header name-value pairs that both endpoints know at connection start. When a header matches an entry, the client sends a single integer (1–61) instead of the full string.

Index 1:  :authority
Index 2:  :method GET
Index 3:  :method POST
Index 4:  :path /
Index 7:  :scheme https
Index 8:  :status 200
...
Index 61: www-authenticate

GET / HTTP/1.1 with a matching path and scheme encodes to roughly 4 bytes instead of 20.

2. Dynamic Table

A per-connection FIFO queue of headers seen during the current session. New entries are inserted at the front, at index 62, pushing existing entries to higher indices; when the table overflows, the oldest entries are evicted from the back. When you send an Authorization: Bearer eyJ... header for the first time, HPACK adds it to the dynamic table. Every subsequent request can reference it by index — regardless of how long the token is.

The dynamic table has a size limit negotiated via SETTINGS_HEADER_TABLE_SIZE (default 4096 bytes).

3. Huffman Encoding

For headers that can't be referenced from either table, HPACK uses a static Huffman code optimized for HTTP header values. Lowercase ASCII letters and digits get the short codes — the shortest is 5 bits — and rare characters run up to 30 bits.

For the actual gains, the best public numbers are Cloudflare's, from their 2016 post HPACK: the silent killer (feature) of HTTP/2, measured across their own edge over a six-hour window. Their figures, quoted rather than paraphrased:

  • "We found that the Huffman encoding alone saves almost 30% of header size."
  • "On average we are seeing a 76% compression for ingress headers."
  • "We can see that the total ingress traffic is reduced by 53% as the result of HPACK compression!"

Read that third number carefully — it is 53% off total ingress traffic, not off headers, and it is that large only because request headers dominate inbound bytes. Egress is the mirror image: 69% compression on response headers, but only 1.4% off total egress traffic, because response bodies swamp the headers. If someone quotes you "53%" as a general HTTP/2 saving, they have taken an inbound-traffic figure and pointed it in the wrong direction.

You can measure your own with h2load, which prints the space savings directly:

$ h2load https://example.com -n 2 | grep traffic
💡

HPACK is deliberately simpler than DEFLATE/gzip. It avoids shared compression contexts that could enable CRIME-style attacks, where an attacker injects known plaintext and observes size changes to recover secrets from compressed headers.

Layer 4: Flow Control

Multiplexing creates a new problem: what stops a fast sender from overwhelming a slow receiver? HTTP/2 uses credit-based flow control at two levels — per-stream and per-connection.

Each endpoint maintains a receive window — a budget of bytes it's willing to accept. The default is 65,535 bytes (2^16 − 1). The sender tracks this window and stops sending DATA frames when it hits zero.

When the receiver processes data and frees up buffer space, it sends a WINDOW_UPDATE frame to grant more credit:

WINDOW_UPDATE frame
Stream ID: 3           ← specific stream, or 0 for connection
Window Increment: 32768  ← bytes of new credit granted

Flow control only applies to DATA frames. HEADERS and control frames bypass it entirely. This matters — a misbehaving client cannot starve header delivery by exhausting the flow control window.

Servers can also advertise a larger initial window size in their SETTINGS frame:

SETTINGS frame
SETTINGS_INITIAL_WINDOW_SIZE: 1048576  ← 1MB instead of 64KB

This is why high-throughput APIs and streaming endpoints often tune this setting.

Layer 5: Server Push

Server push lets the server proactively send resources before the client asks for them. The classic use case: the client requests /index.html, the server knows the client will also need /style.css, so it pushes it immediately.

The mechanism:

  1. Server sends a PUSH_PROMISE frame on the existing stream, containing the synthesized request headers
  2. Server opens a new even-numbered stream and sends the response on it
  3. Client receives the push — either accepts it or cancels with RST_STREAM
Server → PUSH_PROMISE (stream 1, promised stream 2)
         [:method GET, :path /style.css, :scheme https, :authority example.com]

Server → HEADERS (stream 2, END_HEADERS)
         [:status 200, content-type text/css]
Server → DATA (stream 2, END_STREAM)
         [body { margin: 0; } ...]

In practice, server push has a complicated history, and RFC 9113 §8.4 is blunt about why: "In practice, server push is difficult to use effectively, because it requires the server to correctly anticipate the additional requests the client will make, taking into account factors such as caching, content negotiation, and user behavior." If the client already has /style.css, the push is wasted bandwidth. Chrome removed support in Chrome 106 (stable late September 2022), citing that only about 1.25% of HTTP/2 sites used it and that the measured results were mixed, with regressions in many cases.

It's worth being precise about what died. Push was removed from Chrome, not from the specs. HTTP/3 does define server push — RFC 9114 §4.6 "Server Push", with PUSH_PROMISE, CANCEL_PUSH, push IDs and a MAX_PUSH_ID setting. It's specified and unused, which is a different thing from absent, and matters if you're writing an HTTP/3 implementation rather than a web page.

The practical alternative for a web page is Link: </style.css>; rel=preload, plus 103 Early Hints if your stack supports it — both let the client decide, which is the part push got wrong.

The Connection Preface

HTTP/2 connections start with a specific ritual. The client sends a 24-byte connection preface:

PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n

That sequence MUST be followed by a SETTINGS frame, which MAY be empty. The server sends its own preface, also a SETTINGS frame, and each side eventually acknowledges the other's with the ACK flag.

What the client does not do is wait for any of that before making requests. RFC 9113 §3.4: "To avoid unnecessary latency, clients are permitted to send additional frames to the server immediately after sending the client connection preface, without waiting to receive the server connection preface." The first HEADERS frame can be the very next thing on the wire after the 24-octet magic and the SETTINGS frame. If it worked the other way — settings, ACK, then requests — HTTP/2 would have handed back the round trip it was designed to save. The catch is the flip side: the server's SETTINGS may change the rules you were assuming, so anything you send in that window is sent optimistically.

This is also why you can't just type HTTP/2 into a terminal. It's binary from byte 1.

Practical Implications

Head-of-Line Blocking Isn't Fully Solved

HTTP/2 eliminates HTTP-level head-of-line blocking. But it still runs over TCP, which has its own HOL blocking: if a TCP segment is lost, all streams on that connection stall while TCP retransmits. With 6 HTTP/1.1 connections, a packet loss on one doesn't affect the others. With a single HTTP/2 connection, it blocks everything.

This is the exact problem HTTP/3 solves with QUIC — each stream gets independent loss recovery at the transport layer.

Connection Coalescing

HTTP/2 allows a single connection to serve multiple origins if they resolve to the same IP and use the same TLS certificate (via Subject Alternative Names). This means api.example.com and static.example.com can share one connection if they're behind the same CDN — no extra handshake required.

What It Means for Your API Design

With HTTP/1.1, batching was often necessary to avoid connection overhead. GET /api/users,posts,comments was a thing. With HTTP/2, you can issue three separate requests and they arrive at the server simultaneously, processed concurrently, with responses interleaved back to the client. Domain sharding (spreading assets across cdn1., cdn2., cdn3.) actively hurts HTTP/2 performance — it defeats connection coalescing.

Tools like curl --http2 -v https://example.com and Wireshark with the HTTP2 dissector are the best way to see frames in action. Chrome DevTools' Protocol column shows h2 for HTTP/2 connections and you can inspect individual frames in the timing breakdown.

Header Sizing Still Matters

HPACK is smart but not magic. A 4KB cookie still costs 4KB on the first request (before it enters the dynamic table). Large, unique Authorization tokens that change every request won't compress well. The dynamic table is bounded — with hundreds of distinct headers, eviction pressure is real. Keep your headers small and stable.

Where HTTP/3 Takes It

HTTP/3 keeps the layered model — streams, HPACK (replaced by QPACK), flow control — but replaces TCP with QUIC, a UDP-based transport that bakes in:

  • Independent stream loss recovery (no HOL blocking at the transport layer)
  • Built-in TLS 1.3 (0-RTT resumption)
  • Connection migration (same logical connection survives an IP change, useful for mobile)

If you understand HTTP/2's frame model, HTTP/3 will look familiar. QUIC frames map almost directly onto the same concepts, just implemented in user space instead of the kernel TCP stack.

The core insight of HTTP/2 — that you can multiplex independent request/response exchanges over a single bidirectional byte stream — is what makes modern web performance possible. QUIC keeps that insight and fixes the one thing HTTP/2 couldn't: the underlying transport.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

A monotonic stack maintains elements in order and pops when that order breaks — finding the next greater element for every popped value in O(n) total.
AdminAugust 3, 20265 min read
The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read
One file to guard every route — plus the Next.js 16 rename that moves it off the Edge runtime, the request-vs-response header trap that leaks user IDs to the browser, and the CVE that explains why this can never be your only auth layer.
AdminAugust 3, 20268 min read