HTTP/2 and ALPN: one connection, many streams, one failure mode
HTTP/2 multiplexes many streams over one TCP connection and negotiates h2 via ALPN in the TLS handshake — but one connection means transport HoL still bites under loss.
The team flipped on HTTP/2 expecting a free win and watched p99 latency get worse for mobile users. The synthetic tests on the office fibre looked great — dozens of assets loaded over one connection, no more six-connection-per-origin limit. Then real users on lossy LTE started complaining. A single dropped TCP segment was now stalling every in-flight request at once, because all of them shared one connection and TCP delivers bytes strictly in order. Under HTTP/1.1 with six parallel connections, one lost packet froze one connection; the other five kept flowing. HTTP/2 had quietly traded six independent failure domains for one. The fix that actually helped wasn’t a Node setting — it was HTTP/3 over QUIC.
Multiplexing: many streams, one connection
HTTP/1.1’s defining limit is that a connection carries one request at a time. To load a page with 40 assets, the browser opens up to six TCP connections per origin and pipelines requests serially on each — and a slow response head-of-line-blocks everything queued behind it on that connection. HTTP/2 replaces this with multiplexing: a single TCP connection carries many independent streams, each an interleaved sequence of binary frames tagged with a stream ID. Request 7 and request 12 ride the same socket simultaneously, their DATA frames interleaved on the wire, reassembled by stream ID on the other end.
In Node, the http2 core module exposes this directly. A connection is an Http2Session; each request/response pair is an Http2Stream.
import http2 from "node:http2";
import { readFileSync } from "node:fs";
const server = http2.createSecureServer({
key: readFileSync("key.pem"),
cert: readFileSync("cert.pem"),
// ALPN: offer h2 first, fall back to HTTP/1.1 for old clients
ALPNProtocols: ["h2", "http/1.1"],
});
server.on("stream", (stream, headers) => {
// Each `stream` is one Http2Stream multiplexed over the shared session.
stream.respond({ ":status": 200, "content-type": "text/plain" });
stream.end("hello over " + stream.session.alpnProtocol);
});
server.listen(8443);The stream event fires once per request, and many streams belong to one session. That is the whole multiplexing model: the session owns the TCP socket and the connection-wide state (flow control, SETTINGS), while each stream is cheap and independent at the HTTP layer.
| Mode | Transport | ALPN id | How a client starts it | Browser support |
|---|---|---|---|---|
h2 | HTTP/2 over TLS | h2 | ALPN in the TLS handshake | Yes (all modern browsers) |
h2c | HTTP/2 cleartext (no TLS) | none (no TLS handshake) | Prior-knowledge, or HTTP/1.1 Upgrade | No browser supports it |
http/1.1 | HTTP/1.1 over TLS | http/1.1 | ALPN fallback | Yes |
Browsers only ever speak h2 over TLS. h2c (cleartext HTTP/2) exists in the spec — established either by prior knowledge (the client just assumes the server speaks h2c) or by an HTTP/1.1 Upgrade: h2c dance — but no browser implements it, so in practice it shows up only between trusted backend services or behind a TLS-terminating proxy. For anything a browser touches, HTTP/2 means h2, which means TLS, which means ALPN.
ALPN: agreeing on the protocol inside the handshake
How do client and server agree to speak HTTP/2 before sending a single HTTP byte? ALPN — Application-Layer Protocol Negotiation, a TLS extension. During the TLS ClientHello, the client advertises the protocols it can speak, ordered by preference, in the ALPN extension. The server picks one from that list and echoes it back in the ServerHello. By the time the handshake finishes, both sides already know whether this connection is h2 or http/1.1 — zero extra round trips, no Upgrade request.
In Node the ALPNProtocols option (on both createSecureServer and the TLS client) is the ordered list you offer. On the server it is conventionally ["h2", "http/1.1"] — prefer HTTP/2, fall back to 1.1 for legacy clients. Order matters: the array communicates your preference, and Node’s http2 server selects the first mutually-supported entry. Read the negotiated result off stream.session.alpnProtocol (or socket.alpnProtocol) to confirm what was actually chosen.
▸Why this works
Multiplexing fixes HTTP-layer head-of-line blocking: no single slow response blocks the others, because each is its own stream. But it cannot fix transport-layer HoL. TCP guarantees in-order delivery of a single byte stream. When you put 40 HTTP/2 streams on one TCP connection and one segment is lost, TCP holds back all later bytes — for every stream — until the retransmit arrives, because the kernel won’t deliver byte N+1 before byte N. So one lost packet stalls every multiplexed stream at once. HTTP/1.1’s six connections accidentally isolated this: a loss froze one connection, not all six. The real fix is HTTP/3 over QUIC, which runs streams over UDP with per-stream loss recovery, so a lost packet stalls only its own stream. HTTP/2’s multiplexing is genuinely better on a clean link and genuinely worse on a lossy one.
Flow control, SETTINGS, and why Server Push died
If multiplexing is the headline feature, flow control is the plumbing that keeps it from tearing itself apart — and Server Push is the cautionary tale of a feature that sounded great and quietly made things worse. A multiplexed connection needs coordination, and HTTP/2 carries it as control frames. At connect time each side sends a SETTINGS frame declaring limits — most importantly SETTINGS_MAX_CONCURRENT_STREAMS (how many streams the peer may open at once) and the initial flow-control window. Flow control is per-stream and per-connection: a receiver advertises a window of bytes it’s willing to buffer, and WINDOW_UPDATE frames replenish it as data is consumed. This stops one fast stream from drowning a slow consumer, but it’s also a footgun — a too-small window throttles throughput on high-bandwidth-delay-product links (the classic high-latency case where HTTP/2 should shine). Node exposes these via session options like settings: { maxConcurrentStreams } and peerMaxConcurrentStreams.
Server Push was HTTP/2’s headline feature: the server could send a PUSH_PROMISE and proactively push resources (CSS, JS) it predicted the client would need, before the client asked. In practice it failed. Servers pushed assets the browser already had cached, wasting bandwidth on the very lossy links where bytes are most precious; push was hard to prioritise correctly and frequently delayed the critical HTML it was supposed to accelerate. Cache-hit rates were dismal. Chrome removed Server Push support (the change landed in Chrome 106, 2022), and the replacement is 103 Early Hints — an informational response that tells the browser which resources to preload/preconnect while the server is still computing the real response, letting the client decide whether it actually needs them.
A page over h2 stalls completely whenever the mobile network drops a packet, even though every asset is its own stream. Why?
You're deciding whether enabling HTTP/2 will actually help a given workload. Which case benefits most?
- 01Explain how a browser and a Node http2 server agree to speak HTTP/2, and where the ALPNProtocols option fits.
- 02HTTP/2 fixed head-of-line blocking — so why does p99 get worse on lossy mobile networks, and what actually fixes it?
HTTP/2 carries many independent streams over a single TCP connection by interleaving binary frames tagged with stream IDs — in Node, one Http2Session owns the socket and connection-wide state, while each request is a cheap Http2Stream. This multiplexing eliminates HTTP-layer head-of-line blocking and the old six-connections-per-origin workaround. Client and server agree to speak HTTP/2 via ALPN, a TLS extension: the client offers an ordered protocol list (ALPNProtocols, e.g. ["h2", "http/1.1"]) in the ClientHello, the server selects one, and the handshake ends with both sides knowing the protocol — no extra round trips. Browsers only ever speak h2 over TLS; cleartext h2c has no browser support and lives between backend services. The catch is transport-layer HoL: because every stream shares one TCP connection and TCP delivers bytes in order, a single lost segment stalls all streams at once, which is why HTTP/2 can worsen p99 on lossy links — and why HTTP/3 over QUIC, with per-stream loss recovery, is the real fix. Connection coordination rides on SETTINGS frames (concurrency limits, flow-control windows) and WINDOW_UPDATE for per-stream and per-connection flow control. HTTP/2 helps most with many small assets over high-latency links and least for a single large download or assets a CDN already multiplexes. And Server Push is dead — Chrome removed it over poor cache-hit rates and wasted bandwidth; reach for 103 Early Hints instead, letting the client decide what to preload. Now when you see p99 degrade after enabling h2 on a mobile-heavy workload, transport HoL is the first suspect — and the answer is QUIC, not rolling back HTTP/2.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.