Networking

HTTP/2

One TCP connection, hundreds of multiplexed streams

HTTP/2 replaces HTTP/1.1's text protocol with binary frames over a single multiplexed TCP connection. HPACK compresses headers, but TCP head-of-line blocking still hurts — which is why HTTP/3 moved to QUIC.

  • RFC9113 (2022)
  • Wire formatBinary frames
  • Connections per origin1 (vs 6 in HTTP/1.1)
  • Default concurrent streams100+
  • TLSRequired (in practice)

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

How HTTP/2 works

HTTP/1.1 is a text protocol — every request is "GET /index.html HTTP/1.1\r\nHost: example.com\r\n...". To run requests in parallel, the browser opens up to 6 TCP connections per origin, each paying a full handshake plus TLS plus slow-start warm-up. A page with 80 subresources runs them across those 6 connections sequentially — head-of-line blocking at the connection level.

HTTP/2 keeps the same semantics (methods, status codes, headers) and replaces only the wire format. The new format is binary, framed, and multiplexed. Every byte sent on an HTTP/2 connection belongs to a frame:

+-----------------------------------------------+
|                 Length (24)                   |
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |
+-+-------------+---------------+-------------------------------+
|R|                 Stream Identifier (31)                      |
+=+=============================================================+
|                   Frame Payload (0...)                      ...
+---------------------------------------------------------------+

The 9-byte header carries a length, a type (DATA, HEADERS, SETTINGS, PING, RST_STREAM, GOAWAY, WINDOW_UPDATE, PUSH_PROMISE, CONTINUATION, PRIORITY), flags, and a 31-bit stream ID. A request and its response share a stream ID; the connection multiplexes hundreds of streams concurrently, frames interleaved.

The stream ID space is split: client-initiated streams use odd numbers (1, 3, 5, ...) and server-initiated push streams use even numbers (2, 4, 6, ...). Stream 0 is the connection-level control stream — SETTINGS, PING, GOAWAY all flow there.

HTTP versions compared

HTTP/1.0HTTP/1.1HTTP/2HTTP/3
Year19961997 / RFC 7230 (2014)RFC 7540 (2015), RFC 9113 (2022)RFC 9114 (2022)
Wire formatTextText + chunkedBinary framesBinary frames over QUIC
Connection reuseOne request per connKeep-alive + pipeliningMultiplexed streamsMultiplexed streams
Concurrent reqs1 per conn1 per conn (pipelining broken in middleboxes)~100 per conn~100 per conn
Header compressionNoneNoneHPACK (table + Huffman)QPACK
Server pushPUSH_PROMISE (deprecated)Server push (rarely used)
TransportTCPTCPTCP + TLSUDP + QUIC (TLS 1.3 baked in)
HoL blockingPer requestPer connectionPer TCP connectionNone
Setup latency1 RTT (TCP)1 RTT (TCP)2-3 RTT (TCP+TLS)1 RTT (0 with resumption)

JavaScript implementation — Node http2 server with push

// HTTP/2 server with PUSH_PROMISE for the page's CSS.
// (PUSH_PROMISE is deprecated in browsers; this still works in Node-to-Node.)
import http2 from 'node:http2';
import fs from 'node:fs';

const server = http2.createSecureServer({
  key: fs.readFileSync('key.pem'),
  cert: fs.readFileSync('cert.pem'),
});

const HTML = `<!doctype html>
<link rel="stylesheet" href="/style.css">
<h1>Hello</h1>`;
const CSS = `body { font-family: system-ui; }`;

server.on('stream', (stream, headers) => {
  const path = headers[':path'];
  if (path === '/') {
    // Push the CSS before the client knows to ask for it.
    stream.pushStream(
      { ':path': '/style.css' },
      (err, pushStream) => {
        if (err) return;
        pushStream.respond({
          ':status': 200,
          'content-type': 'text/css',
        });
        pushStream.end(CSS);
      }
    );
    stream.respond({ ':status': 200, 'content-type': 'text/html' });
    stream.end(HTML);
  } else if (path === '/style.css') {
    stream.respond({ ':status': 200, 'content-type': 'text/css' });
    stream.end(CSS);
  } else {
    stream.respond({ ':status': 404 });
    stream.end();
  }
});

server.listen(8443);

This is the canonical "push the CSS your client will need next" idiom. In production you would replace pushStream with a 103 Early Hints response carrying Link: </style.css>; rel=preload — the modern alternative that Chrome and Firefox actually honor.

Python implementation — multiplexed requests with httpx

"""
Fire 50 concurrent HTTP/2 requests over a single connection and
demonstrate that multiplexing is real — the requests interleave on
the wire instead of queueing.
"""
import asyncio
import time
import httpx

async def main():
    # http2=True forces ALPN negotiation of "h2"
    async with httpx.AsyncClient(http2=True) as client:
        urls = [f"https://nghttp2.org/httpbin/delay/0.5?n={i}" for i in range(50)]
        t0 = time.perf_counter()
        responses = await asyncio.gather(
            *(client.get(u) for u in urls)
        )
        elapsed = time.perf_counter() - t0
        h2_count = sum(1 for r in responses if r.http_version == "HTTP/2")
        print(f"50 reqs in {elapsed:.2f}s, {h2_count} over HTTP/2")
        # Compare: HTTP/1.1 with 6 connections would take ~5s

asyncio.run(main())

50 requests each costing 500 ms of server-side delay take ~0.6 s total over HTTP/2 (one RTT, all multiplexed). Over HTTP/1.1 with 6-connection cap they take 50 / 6 × 0.5 ≈ 4.2 s. The multiplexing win is most visible on slow servers; on local-loopback the connection count matters less than handshake overhead.

Concrete costs and gains

  • Connection count: 1 per origin vs 6 in HTTP/1.1. A page with 80 subresources from one origin opens 1 connection, paying TLS+slow-start once. The same page over HTTP/1.1 opens 6 connections and 6 TLS handshakes.
  • Header overhead: A typical request header set is ~600 bytes. HPACK's static table covers 61 common headers; dynamic table covers recent values. After warm-up, the same User-Agent + Cookie repeated across 100 requests costs ~5 bytes per request instead of ~600.
  • HoL blocking at TCP: One lost segment in the TCP stream stalls every HTTP/2 stream until the retransmit arrives — typically 1 RTT (50 ms on a continental link, 200 ms on transatlantic). With 100 concurrent streams sharing the connection, every loss multiplies its damage.
  • Setup cost: 1 RTT TCP + 2 RTT TLS 1.2 = 3 RTT before first byte. TLS 1.3 cuts this to 2 RTT. QUIC cuts it to 1 RTT (0 with resumption).
  • Server push abandoned: Chrome team measured pushed resources were unused or duplicated cache entries ~80% of the time. Removed in Chrome 106 (2022). Replaced by 103 Early Hints.
  • HPACK dictionary attack: CVE-2019-9518 (HTTP/2 ping flood), CVE-2023-44487 (HTTP/2 Rapid Reset). Single connection × 100 streams × abuse = DoS amplification.

Variants and related features

  • 103 Early Hints (RFC 8297) — informational status preceding the final response. Server responds with 103 + Link: rel=preload headers, then continues working on the real response. Browsers begin fetching preloads while the origin processes. Replaced server push in practice.
  • Server Push deprecation — Chrome removed in 2022, Firefox followed. Spec retains it; nobody uses it for browser traffic. Still occasionally useful in API-to-API scenarios where you control both ends.
  • Stream Priorities (RFC 7540) — clients could declare a dependency tree among streams. Implementation across servers and CDNs was inconsistent; superseded by RFC 9218 Extensible Prioritization Scheme using Priority headers.
  • SETTINGS frames — per-connection knobs: SETTINGS_MAX_CONCURRENT_STREAMS, SETTINGS_INITIAL_WINDOW_SIZE, SETTINGS_MAX_FRAME_SIZE, SETTINGS_HEADER_TABLE_SIZE. Sent at connection start; either side can update.
  • 100-continue handling — clients send POST headers, wait for 100 Continue, then send body. HTTP/2 retains this; the 100 is delivered as a HEADERS frame on the same stream before the final response.
  • Cleartext h2 (h2c) — defined in RFC 9113 §3.4 as Upgrade-from-HTTP/1.1. No browser supports it. Server-to-server only (gRPC commonly does this internally).
  • WebSocket over HTTP/2 (RFC 8441) — bootstrap WebSocket on a stream rather than a separate Upgrade. Adoption mixed; many WS implementations still prefer HTTP/1.1.

Common bugs and edge cases

  • Head-of-line blocking at TCP. Multiplexed streams give the illusion of independence, but a single dropped TCP segment stalls all 100 streams. On lossy mobile, HTTP/2 can be slower than HTTP/1.1 because 6 TCP connections each had their own sequence space and only one would stall. Measure on real networks before committing.
  • Settings frame ordering. The connection preface is the magic string "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" followed by an immediate SETTINGS frame from each side. Skipping or misordering this is a common bug in hand-rolled clients; servers will GOAWAY with PROTOCOL_ERROR.
  • RST_STREAM amplification (Rapid Reset). CVE-2023-44487 — an attacker opens stream 1, immediately sends RST_STREAM, then opens stream 3, etc. The server pays full request-handling cost; the attacker pays only frame-sending cost. Mitigation: cap RST_STREAM rate per connection; close abusive connections.
  • HPACK dynamic table desync. If client and server disagree on the dynamic table size after a SETTINGS update, header decoding silently corrupts. The HPACK spec mandates SETTINGS_HEADER_TABLE_SIZE be acknowledged before applying — easy to miss.
  • Connection coalescing surprise. Browsers reuse one HTTP/2 connection for multiple origins if the certificate covers all of them and DNS resolves to the same IP. A single misbehaving origin can starve the others through HoL blocking.
  • Push promise unsolicited. If the client SETTINGS_ENABLE_PUSH is 0, sending PUSH_PROMISE is a connection error. Servers must check the negotiated value before pushing.
  • Cookie growth. HPACK saves on repeat headers, but if cookies grow or rotate, the dynamic table churns and effective compression drops. Aggressively split cookies or move state to short tokens.

Frequently asked questions

What's the headline difference vs HTTP/1.1?

Multiplexing. HTTP/1.1 needs one TCP connection per concurrent request (browsers cap at 6 per origin). HTTP/2 runs hundreds of concurrent streams over one connection, so a page with 80 assets uses 1 connection instead of 14, eliminating per-connection TCP slow-start overhead.

Did HTTP/2 fix head-of-line blocking?

Only at the HTTP layer. Streams within HTTP/2 are independent and don't block each other. But all streams ride on one TCP connection, and a single lost TCP segment freezes every stream until that segment is retransmitted. HTTP/3 over QUIC fixes this by giving each stream its own loss-recovery state.

What is HPACK?

Header compression for HTTP/2. Both ends maintain a synchronized table of recently-seen header fields, so repeat headers (User-Agent, Cookie, Accept) ship as a 1-byte index. HPACK was designed to resist the CRIME attack that broke gzip-based header compression in SPDY.

Is server push actually used?

Mostly no. Chrome removed support in 2022 because the cache-fill heuristic was wrong more often than right — pushed resources were already cached, or the wrong subresources were chosen. Modern HTTP/2 deployments use 103 Early Hints to let the server suggest preloads without speculative push.

Does HTTP/2 require TLS?

The RFC permits cleartext HTTP/2 ("h2c"), but every browser refuses it. In practice HTTP/2 always uses TLS 1.2+ with ALPN negotiating the "h2" identifier during the TLS handshake. Servers and intermediaries that support cleartext h2 are server-to-server only.

How many concurrent streams can a connection have?

The peer advertises SETTINGS_MAX_CONCURRENT_STREAMS — RFC 9113 recommends ≥ 100. Most servers default to 100-256. Streams 0, even-numbered (server-initiated push), and odd-numbered (client-initiated) live in the same numeric space.