HTTP/2: Streams on One Connection
HTTP/2 replaces text lines with binary frames tagged by stream id, so many requests and responses interleave on a single TCP connection with compressed headers — at the cost that one lost TCP segment now stalls every stream on that connection.
The problem
Frames and streams
HTTP/2 (RFC 9113, 2015 originally as RFC 7540) keeps HTTP’s semantics — the same methods, status codes and headers — and replaces the wire format. Everything is a frame: a 9-byte header carrying the payload length, a type, flags and a 31-bit stream identifier, followed by the payload. A request is a HEADERS frame (plus CONTINUATION if large) and optional DATA frames on a new stream; the response is HEADERS and DATA frames on the same stream, with the END_STREAM flag marking the end. Because every frame says which stream it belongs to, frames from different streams can be interleaved freely on one connection.
Streams are cheap: a client opens one per request by picking the next odd id (servers use even ids), and closes it with END_STREAM or aborts it with RST_STREAM — cancelling one download no longer means dropping a TCP connection. The server advertises SETTINGS_MAX_CONCURRENT_STREAMS (commonly 100–250); within that limit the browser sends every request the page needs the moment it knows about it, and the server answers in whatever order it produces results. Control frames (SETTINGS, PING, GOAWAY, WINDOW_UPDATE) travel on stream 0.
The connection begins with a fixed 24-byte preface (PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n) and a SETTINGS exchange; the protocol itself is chosen during the The TLS Handshake via ALPN (h2). Cleartext h2c with an Upgrade exists in the RFC and is used by some internal gRPC deployments, but browsers only speak HTTP/2 over TLS — in practice h2 requires TLS on the web.
Header compression, prioritisation, push
Headers repeat: the same User-Agent, Accept, Cookie and Authorization go out on every request. HPACK (RFC 7541) compresses them with a 61-entry static table of common name/value pairs (:method GET, :status 200, accept-encoding gzip, deflate), a per-connection dynamic table where each side remembers headers it has seen, and Huffman coding for the rest. A repeated header becomes a one-byte index; a typical 700-byte request header block shrinks to tens of bytes. HPACK deliberately avoids general-purpose compression like gzip because CRIME showed that compressing attacker-controlled input next to a secret leaks the secret through the compressed size.
HTTP/2 also carries pseudo-headers (:method, :path, :scheme, :authority) instead of the request line, forces header names to lowercase and forbids Connection, Transfer-Encoding and other hop-by-hop headers — the framing handles what they used to.
Prioritisation in the original RFC let the client build a dependency tree with weights so a server could send CSS before images. Server support was inconsistent, some implementations ignored it, and RFC 9218 replaced the tree with a simpler priority header (urgency 0–7 plus an incremental flag) shared with HTTP/3. Server push — the server sending a PUSH_PROMISE and a resource the client had not asked for — is effectively dead: it was hard to use without wasting bandwidth on already-cached resources, Chrome removed support in 2022 and other browsers followed or never enabled it. 103 Early Hints with Link: rel=preload is the replacement: the server tells the client what to fetch and the client decides.
- HPACK: static table + per-connection dynamic table + Huffman; a repeated header costs about one byte.
- Pseudo-headers replace the request/status line; header names are lowercase; hop-by-hop headers are forbidden.
- Prioritisation: RFC 7540 dependency tree superseded by RFC 9218
priorityheader; honoured to varying degrees. - Server push: specified, deprecated in practice — removed from Chrome; use
103 Early Hints+ preload instead.
Flow control and the cost: one TCP stream underneath
Multiplexing forces HTTP/2 to have its own flow control: a fast stream must not starve a slow consumer of a different stream on the same connection. Each stream and the connection as a whole have a receive window (default 65,535 bytes) advanced with WINDOW_UPDATE frames — a layer of windows above TCP’s rwnd (Flow Control: The Receive Window). A client that forgets to grow its windows caps every download at 64 kB per RTT, which is a real and recurring bug in HTTP/2 client libraries.
The cost is that all streams share one TCP byte stream. TCP guarantees in-order delivery of the whole stream, so when one segment is lost, every byte after it — belonging to every stream — waits in the receiver’s buffer until the retransmission arrives. HTTP/2 removed the application-level head-of-line block of HTTP/1.1 and exposed the transport-level one underneath (Head-of-Line Blocking). On a clean network that is invisible; on a lossy mobile link measurements have shown HTTP/1.1 with six independent connections outperforming HTTP/2 with one, because a loss stalls one-sixth of the transfer instead of all of it. That observation is the motivation for HTTP/3 and QUIC.
A single long-lived connection has operational consequences too. A load balancer that balances *connections* sends all of one client’s requests to one backend, so Load Balancers: L4 vs L7 for h2 traffic must balance at the request (L7) level or accept imbalance. And cheap stream creation is an attack surface: the 2023 "Rapid Reset" attack (CVE-2023-44487) opened and immediately RST_STREAM-cancelled streams faster than servers could process them, producing record-size denial-of-service floods with modest client resources.
Key points
- HTTP/2 keeps HTTP semantics and changes the wire format: binary frames with a stream id, so many requests interleave on one connection.
- Streams are cheap and cancellable (
RST_STREAM); the server caps concurrency withSETTINGS_MAX_CONCURRENT_STREAMS. - HPACK compresses headers with static and dynamic tables; a repeated header costs about a byte.
- Prioritisation is now the RFC 9218
priorityheader; server push is deprecated in practice — use103 Early Hints. - Browsers speak HTTP/2 only over TLS, negotiated with ALPN;
h2cexists for internal use. - All streams share one TCP stream, so one lost segment stalls all of them: transport-level head-of-line blocking, which QUIC was built to address.
- Per-stream and per-connection flow-control windows sit above TCP’s; forgetting to grow them throttles throughput.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why binary framing rather than fixing text HTTP?
Interleaving needs an unambiguous, cheap-to-parse frame boundary and a stream id on every piece; text lines give neither. Fixed-size binary headers are parsed in a few instructions and have no precedence ambiguities.
▸Why a custom compression scheme instead of gzip?
CRIME and BREACH showed that a compressor sharing context between an attacker’s input and a secret leaks the secret by size. HPACK’s table-based scheme lets an attacker learn at most that a whole header matched, and implementations can refuse to index sensitive headers.
▸Why did server push fail?
The server cannot know what the client has cached, so it pushed bytes the client would discard; it competed with the client’s own priorities; and the win over preload hints was small. Telling the client what to fetch (103 Early Hints) keeps the decision where the cache is.
▸Why does HTTP/2 need its own flow control when TCP has one?
TCP’s window governs the whole connection; a single stream consumed slowly by the application would fill it and stall every other stream. Per-stream windows let the receiver throttle one stream without blocking the rest.
HTTP/2 streams
t = 0 ms (one connection) HEADERS stream=1 len=180 HEADERS stream=3 len=40 HEADERS stream=5 len=40 HEADERS stream=7 len=40
How it fails
What the failure looks like from inside real software.
- Throughput capped around 64 kB per RTT: a client that never sends
WINDOW_UPDATEbeyond the default window; downloads are slow while the link is idle. - HTTP/1.1-era sharding across four hostnames under HTTP/2 now costs four TLS handshakes and four cold connections instead of one — the optimisation became a regression.
- L4 load balancer plus HTTP/2: all requests from a busy client land on one backend; utilisation is lopsided and one backend saturates.
- A lossy mobile link makes a single h2 connection slower than six h1 connections: transport head-of-line blocking dominates.
- Rapid Reset (CVE-2023-44487): thousands of streams opened and cancelled per second overwhelm the server; mitigation is rate-limiting
RST_STREAMand stream creation. - A proxy that speaks h2 to clients but h1 with a single connection to the origin serialises all streams behind it; the multiplexing is lost at the hop nobody looked at.