Skip to content

HTTP/2 and multiplexing

HTTP/1.1 worked around its own inability to run multiple requests on one connection by opening six connections instead. HTTP/2, standardized in 2015 as RFC 7540 and later refined as RFC 9113, removes the need for that workaround by fixing the actual problem: a single HTTP/2 connection can carry many requests and responses genuinely concurrently, with no fixed six-connection ceiling and no waiting for one response before the next request's response can arrive.

From a text protocol to a binary, framed one

HTTP/1.1 messages are plain text with a specific line-based structure — a request line, headers, a blank line, a body. That structure is easy to read in a terminal, but it's fundamentally hard to multiplex: without an explicit length or boundary marker on every piece, a parser reading a stream that mixes several requests together has no reliable way to tell where one message ends and the next begins.

HTTP/2 solves this by discarding the text format for the wire representation and replacing it with a binary framing layer. Every unit HTTP/2 sends — a set of headers, a chunk of body data, a settings update — is wrapped in a frame with an explicit type, length, and a stream identifier. A stream is a single logical request/response exchange; a connection can have many streams open at once, and frames belonging to different streams can be interleaved on the wire in any order, because the stream ID on every frame tells the receiver exactly which exchange it belongs to.

graph LR
    subgraph "Single TCP connection"
        A["Stream 1 frame<br/>(HEADERS)"] --> B["Stream 3 frame<br/>(DATA)"]
        B --> C["Stream 1 frame<br/>(DATA)"]
        C --> D["Stream 5 frame<br/>(HEADERS)"]
        D --> E["Stream 3 frame<br/>(DATA)"]
    end

This is multiplexing in the concrete sense: streams 1, 3, and 5 are three independent requests, all in flight on the same TCP connection at the same time, with their frames genuinely interleaved rather than queued strictly one after another. A slow response on stream 3 no longer blocks a fast response on stream 1 from being delivered — the exact application-layer head-of-line blocking problem the previous article described is gone, because there's no longer a strict "finish this response before starting the next" ordering requirement at the HTTP layer at all.

One visible side effect of the binary format: HTTP/2 also drops the human-readable status line's reason phrase. A response that would read HTTP/1.1 200 OK in the previous article's captures appears as HTTP/2 200 — the numeric code is transmitted, but the "OK" text isn't part of the wire format anymore, since nothing at the protocol level actually needs it.

HPACK: compressing headers across a connection's whole lifetime

A subtler cost in HTTP/1.1 is header repetition: browsers commonly attach a dozen or more headers to every request — User-Agent, Accept, Cookie, and several others — and most of them are identical, byte for byte, across every request in a session. Sending the full text of those headers on every single request, uncompressed, is redundant in a way that adds up fast on a page with dozens of resources.

HTTP/2 fixes this with HPACK (RFC 7541), a compression scheme built specifically for HTTP headers rather than a generic compressor. HPACK maintains a table of previously seen header fields on both sides of the connection — a fixed static table of the most common header names and values, defined by the spec itself, plus a dynamic table that grows as the connection actually uses new header fields. Once a header has appeared once, later requests can reference it by a short index number instead of resending the full text, and headers that haven't been seen before can still be Huffman-coded to shrink their size even on first use.

The practical effect: on a connection carrying many requests to the same origin, the marginal cost of headers on each additional request drops sharply after the first one, since most of what a browser sends repeats request after request.

What multiplexing didn't fix: TCP is still one ordered stream

HTTP/2 still runs on top of TCP, and this is where its improvement runs into a hard limit it doesn't control. TCP guarantees strictly ordered, reliable delivery for everything on a connection — that's the entire point of TCP's sequence numbers and retransmission logic. But "everything on a connection" doesn't know or care about HTTP/2 streams; TCP sees one undifferentiated byte stream, not three separate logical exchanges.

If a single TCP segment is lost, TCP must hold every byte that arrived after it — regardless of which HTTP/2 stream those bytes belong to — until the lost segment is retransmitted and arrives. A frame for stream 1 that arrived perfectly fine, sitting right behind the lost segment in the TCP byte stream, still can't be delivered to the application until TCP finishes reassembling the gap in front of it. HTTP/2's own framing has no way around this, because the blocking is happening one layer down, at the transport layer HTTP/2 has no visibility into. This is genuinely a different, narrower problem than HTTP/1.1's — it takes actual packet loss to trigger, not just one slow response — but on a network with real loss, like a congested Wi-Fi network or a mobile connection under load, it's a real, measurable stall affecting every stream on the connection at once.

Server push: introduced, then mostly withdrawn

The original HTTP/2 specification also introduced server push — letting a server proactively send a resource the client hasn't asked for yet, anticipating that a request for it is coming (sending a page's CSS file alongside the HTML that references it, before the browser has even parsed the HTML to discover it needs that CSS). In practice, push delivered inconsistent, often negative results — pushing resources the client already had cached wasted bandwidth, and getting the prioritization right proved harder than expected. Adoption stayed under 1% of HTTP/2 sites that supported it at all, and major browsers, including Chrome, disabled push by default starting in 2022. It's mentioned here mainly so the term doesn't surprise you if it comes up in older material; it isn't something worth designing around today.

Practical scenario: a video call that freezes on both video and chat at once

A team debugging a browser-based video conferencing tool, built on a single HTTP/2 connection carrying both the video stream and a text chat channel as separate logical streams, notices that on a congested office Wi-Fi network, chat messages stop appearing for several seconds at exactly the moments video visibly stutters — even though chat is a tiny amount of data compared to video and, if the streams were truly independent, should be unaffected by video's bandwidth problems.

ss -i on the client during a reproduced stall shows retransmissions accumulating on the single TCP connection to the conferencing server:

ss -ti dst 203.0.113.40
ESTAB 0 0 10.0.2.15:54210 203.0.113.40:443
     cubic wscale:7,7 rto:412 rtt:103.4/28.6 mss:1400 cwnd:6 ssthresh:9 bytes_sent:812400 bytes_retrans:38200 retrans:4/61 reordering:3

retrans:4/61 — four retransmitted segments out of sixty-one sent recently — confirms real packet loss is occurring on this connection, and cwnd:6 shows TCP's congestion window has been cut down hard in response, matching what congestion control on loss looks like. Since video and chat share this one TCP connection, a lost segment belonging to the video stream forces TCP to withhold everything behind it in delivery order — including chat frames that arrived intact — until the retransmission completes. The chat messages aren't lost or slow to send; they're sitting fully received in the client's TCP receive buffer, blocked from being handed to the application by data in front of them that hasn't arrived yet.

This is precisely the TCP-layer head-of-line blocking this article describes, made visible through packet loss statistics rather than through HTTP itself — HTTP/2's own framing is working exactly as designed, and the bottleneck is one layer down, outside what HTTP/2 can fix.

Practice exercises

  1. Using the ss -ti output above as a reference, explain what cwnd:6 combined with a nonzero retrans count tells you about what happened just before this snapshot was taken.
  2. A colleague argues that opening two separate HTTP/2 connections instead of one would avoid the freezing behavior in this scenario. Evaluate whether this actually solves the underlying problem, and what it would cost if it did help.
  3. Explain, in your own words, why HPACK's dynamic table means the order in which headers first appear on a connection can affect compression efficiency for requests that follow.

TCP-layer head-of-line blocking is exactly the problem HTTP/3 was designed to remove — not by working around TCP more cleverly, but by replacing it entirely with a transport where loss on one stream genuinely can't block another.

Sources