TRT
Sign in
Networking

HTTP/2 Multiplexing and Connection Management

How HTTP/2 fixes head-of-line blocking with binary framing and multiplexing, and why connection reuse matters for latency.

The Runtime Theory Team7 min read#http2#multiplexing#connection-management#latency

HTTP/2 was designed to fix one measurable problem: HTTP/1.1 makes a browser wait. Not for the network — for itself. Understanding what exactly it fixes, and what it deliberately leaves broken, is the difference between reading a protocol spec and being able to explain why a page got slower after a proxy upgrade.

The HTTP/1.1 problem

Two limits define HTTP/1.1's performance:

  1. One request at a time per connection (without pipelining). Browsers work around it by opening up to 6 connections per host — so a page with 80 resources runs in ~14 waves at best, each wave paying the same round trip. Worse, if one response is slow (a large image from a busy origin), the small CSS file queued behind it on that connection waits too. This is head-of-line blocking: the transport is fine, the application layer serialized the requests.
  2. Verbose, repeated headers. A browser's headers are mostly the same on every request — user-agent, accept, accept-encoding, cookies — commonly 800–1,000 bytes per request with only a few dozen unique. Eighty requests on a page re-send roughly 64 KB of near- identical header bytes.

Pipelining (sending requests without waiting) was specified to fix the first limit and was essentially never deployed: intermediate proxies broke it, and head-of-line blocking moved into the response queue instead. HTTP/2 attacked both problems at the framing layer.

Frames and streams: the actual fix

HTTP/2 encodes everything as small binary frames. Frames belonging to one request/response pair share a stream ID; frames from different streams interleave freely on one TCP connection. A slow body on stream 1 no longer delays headers on stream 3.

Every connection starts with a 24-byte connection preface the client must send, so a server can tell HTTP/2 from leftover HTTP/1.1 traffic:

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

You can watch the multiplexing happen:

bash
$ curl -sI --http2 https://example.com | head -3
HTTP/2 200
content-type: text/html; charset=UTF-8
cache-control: max-age=120
 
$ curl -v --http2 https://example.com -o /dev/null 2>&1 | grep -i "http2\|multiplex"
* Using HTTP2, server supports multiplexing
* Connection #0 is now complete  # one connection, all requests

Or go frame-level with nghttp -nv https://example.com, which prints one block per frame: SETTINGS at connect, then interleaved HEADERS/DATA pairs for each stream, then RST_STREAM or END_STREAM to close them. Note the rule that keeps ordering sane: a client initiated stream uses an odd ID, the server answers on even IDs, and stream 0 is reserved for connection-level control.

Flow control and header compression

Multiplexing would be useless if one huge download starved everything else, so HTTP/2 has two layers of flow control: a connection window and a per-stream window. The initial stream window is 65,535 bytes unless enlarged by SETTINGS_INITIAL_WINDOW_SIZE, and the receiver replenishes it with WINDOW_UPDATE frames as the application reads data. An application that does not read its socket freezes its own stream — not the connection.

HPACK fixes the header overhead. It is not general compression; it is a codec with three tools: a static table of 61 common fields (:method GET is one indexed byte), a dynamic table shared across streams on that connection (default 4,096 bytes — once user-agent is sent, every later stream refers to it by index), and Huffman coding for literal values. In practice the repeated 800-byte header set collapses to a few dozen bytes per request. The shared state is also a security constraint: HPACK is stateful precisely so that compression cannot be used as an oracle to leak secrets across streams.

Server Push was HTTP/2's third headline feature — sending CSS before the HTML asks for it — but it requires the server to predict the client's cache accurately, wastes bytes when it guesses wrong, and has since been removed from browsers. Expectations now favor 103 Early Hints and preload hints instead.

Priority: the weak spot

HTTP/2's original dependency-tree priority (RFC 7540) turned out to be impractical: every browser built a different tree, most servers ignored it, and there was no way to express "this matters ten times more than that." RFC 9218 replaced it with a flat urgency + incremental model — urgency orders streams, incremental marks byte streams that should progress steadily rather than finish in bursts. It is simpler and largely what implementations ship today, but do not assume the browser's priorities survive your proxy: many load balancers rewrite or drop them.

What multiplexing does not fix: TCP head-of-line blocking

Streams share one TCP connection, so they share one loss-recovery queue. When a TCP segment is lost, TCP withholds every byte above the gap until the retransmission arrives — all streams stall, even ones whose bytes arrived fine. On a lossy link (Wi-Fi, mobile) with a 100 ms RTT and fast retransmit, that stall is roughly 100–200 ms applied to your whole page.

HTTP/3 moves streams into the transport itself: QUIC over UDP gives each stream its own loss-recovery state, so a lost packet blocks one stream. QUIC also folds the cryptographic handshake into the transport handshake — 1 RTT on a fresh connection, 0 RTT on a returning one — versus HTTP/2's TCP handshake plus TLS. The result is not primarily "faster bytes"; it is less coupling between unrelated requests.

Where this bites in production

  • Silent fallback to HTTP/1.1. If ALPN negotiation fails (an old proxy, a misconfigured load balancer), the browser falls back and you lose multiplexing entirely — with 6 connections instead of 1. curl -v and browser devtools both show the negotiated protocol; check it after every infrastructure change.
  • Stream limits. Servers advertise MAX_CONCURRENT_STREAMS (commonly 100–256). Clients queue above that limit, and if your client library ignores the advertisement, it gets RST_STREAM refusals that surface as random PROTOCOL_ERROR failures.
  • Connection-level failure is amplified. One TCP connection now carries every request; a load balancer idle-timeout shorter than your client's keep-alive (a recurring nginx vs. client-pool mismatch) tears down all in-flight streams at once — the failure looks like a thundering herd of resets, not a graceful close.
  • Priorities, pushes, and stream resets all behave differently behind every proxy in the path. When HTTP/2 "isn't helping," compare against --http1.1 before assuming the code is at fault.

Read the full request lifecycle · Learn about CDNs

Spotted a mistake or something broken? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save