
In the 16 years following HTTP/1.1's standardization (1999), the web transformed dramatically. Ajax in 2005 meant pages no longer required full refreshes. After 2010, SPAs (Single-Page Applications) emerged — a single page might fire off hundreds of API and resource requests. Yet the HTTP protocol itself didn't change.
HTTP/1.1's persistent connections and caching mechanisms certainly reduced TCP handshake overhead per request, but three fundamental problems — protocol-level head-of-line blocking, header redundancy, and connection concurrency limits — were unsolvable within the HTTP/1.1 semantic framework. Front-end teams developed workarounds: domain sharding, CSS Sprites, file concatenation. These were effective, but they were clever hacks that worked around protocol flaws, not fundamental solutions.
In May 2015, HTTP/2 (RFC 7540) was officially released. It made a bold decision: preserve all of HTTP/1.1's semantics (methods, status codes, header fields), but completely overhaul the transmission mechanism.
Understand HTTP/1.1's persistent connections and request-response model
Understand TCP's sequence numbers, acknowledgment mechanism, and congestion control basics
Familiarity with TLS protocol basics (HTTP/2 over TLS is the current standard practice)
Open your company's admin dashboard. Chrome DevTools' Network panel shows:
app.example.com, api.example.com, cdn.example.com)If this system runs on HTTP/1.1, your browser must maintain 18 TCP connections simultaneously. Each connection goes through a three-way handshake and TLS negotiation on setup. Each connection starts from TCP slow start (the congestion window grows exponentially from ~10 MSS).
Switch to HTTP/2, and your browser only needs one TCP connection to app.example.com, multiplexing 100+ requests over that single connection. No connection-contending TLS overhead. No fragmented slow-start processes. Bandwidth utilization improves dramatically.
But here's the critical question: with only one TCP connection, if a single packet is lost, does it block all in-flight requests? Yes — HTTP/2 solved HTTP-layer head-of-line blocking, but not TCP-layer head-of-line blocking. This perfectly illustrates the tension between HTTP and TCP: HTTP/2 pushes HTTP efficiency to the very limits of what TCP can handle.
Before diving into HTTP/2, let's be precise about what HTTP/1.1 gets wrong. These are protocol design issues — not problems you can fix by adding a CDN.
TCP only cares about delivering a reliable byte stream. It has no idea whether a TCP segment contains the body of HTTP request A or the headers of HTTP request B. So on a single TCP connection, if response A is delayed for some reason (say A is the result of a slow database query, and data is trickling out), response B must wait — even if B is fully ready on the server side.
This is different from TCP-layer head-of-line blocking. TCP-layer HOL blocking happens when "a TCP segment arrives out of order and the receiving end must wait for the missing segment before delivering data to the application." HTTP-layer HOL blocking is a semantic issue: HTTP/1.1 has no concept of "parallel response fragmentation" — a response is a complete message that must be returned in order.
# HTTP/1.1's serial constraint
Request A (large, slow) ──────────────────────────────────────────→ Response A (takes forever)
Request B (small, fast) ──────→ Response B (forced to wait)
# The desired behavior
Request A ──────────────────────────────────────────→ Response A (streams slowly)
Request B ──────→ Response B (finishes first)HTTP is stateless, so every request must carry the full context. Cookies can be hundreds of bytes. User-Agent strings easily exceed a hundred bytes. The Authorization header is identical across requests. In HTTP/1.1, this information is repeated in full with every single request.
From TCP's perspective, this redundant data is most damaging during slow start — the initial congestion window is only about 14 KB, and header bloat consumes a meaningful share of it, squeezing out genuinely useful payload space.
Browsers default to 6 TCP connections per domain. Six connections sounds like enough, but a modern page has 100+ resources, meaning each connection processes a dozen requests serially, compounding the head-of-line blocking effect.
The entire HTTP family (including HTTP/2) follows the request-response model — the server cannot proactively push data to the client. But in some scenarios, when the server receives an HTML request, it already knows the client will inevitably need a certain CSS and JS file next. Rather than waiting for the client to parse the HTML, discover the <link> and <script> tags, and then issue requests, why not push those resources along proactively?
HTTP/2 added no new request methods and changed no status codes. Its entire contribution can be summarized in one sentence: insert a binary framing layer between HTTP semantics and TCP, replacing HTTP/1.1's plaintext message model with the concepts of frames and streams.
This is HTTP/2's most fundamental innovation. Let's look at the protocol stack relationship:
HTTP/1.1 stack:
HTTP/1.1 plaintext messages
─────────────────
TLS Record Layer (optional)
─────────────────
TCP (byte stream)
─────────────────
IP
HTTP/2 stack:
HTTP semantics (methods, status codes, header fields)
─────────────────
HTTP/2 binary framing layer ← NEW
─────────────────
TLS Record Layer (optional)
─────────────────
TCP (byte stream)
─────────────────
IPThis new framing layer acts as a "translator": toward the upper layer (HTTP semantics), it preserves all familiar HTTP concepts (GET, 200 OK, Content-Type); toward the lower layer (TCP), it produces compact binary frame sequences.
An HTTP/1.1 request looks like this (plaintext):
GET /api/users HTTP/1.1
Host: example.com
Accept: application/jsonThe same request in HTTP/2 becomes:
:method: GET, :path: /api/users, host: example.com, etc.Each frame carries a stream ID identifying which logical request it belongs to. Stream IDs are 31-bit integers: client-initiated requests use odd numbers (1, 3, 5...), server pushes use even numbers (2, 4, 6...).
From TCP's perspective, it still receives a byte stream — TCP knows nothing of frame boundaries. But the receiving HTTP/2 parser can slice frames from the byte stream, read stream IDs, and reassemble messages by stream. This is the foundation of multiplexing.
With stream IDs, frames from multiple requests can be interleaved:
Client Server
|── HEADERS frame (Stream 1) ──────────────────→| Request A begins
|── DATA frame (Stream 1, partial) ────────────→| Request A body
|←─ HEADERS frame (Stream 1) ──────────────────| Response A headers
|── HEADERS frame (Stream 3) ──────────────────→| Request B begins (doesn't wait for A to finish)
|←─ DATA frame (Stream 1) ─────────────────────| Response A body (continuing)
|── DATA frame (Stream 3) ─────────────────────→| Request B body
|←─ HEADERS frame (Stream 3) ──────────────────| Response B headers
|←─ DATA frame (Stream 3) ─────────────────────| Response B bodyNotice what happened: Response A's DATA frames and Request B's HEADERS frame are interleaved in the same direction. In HTTP/1.1 this is impossible — Response A must complete before Request B can even begin. The key to multiplexing is that the stream ID provides message ownership, allowing the receiver to reassemble by stream without waiting for other streams to complete.
The performance impact is massive:
But there's a subtle limitation. While HTTP/2 solves HTTP-layer head-of-line blocking, it still runs over TCP. If TCP loses a segment that requires retransmission, all streams — including those unrelated to the lost segment — are blocked, because TCP must receive the missing segment before delivering subsequent bytes to the application. This is inherent to TCP and HTTP/2 cannot bypass it.
TCP-layer head-of-line blocking (HTTP/2 can't fix):
TCP segment #5 (belongs to Stream 1) is lost
→ TCP segment #6 (belongs to Stream 3) has arrived, but TCP can't deliver it upward
→ Stream 3 is indirectly blocked by Stream 1's packet lossThis is exactly why HTTP/3 abandoned TCP for UDP-based QUIC — it moved reliable delivery from a "per-connection" scope to a "per-stream" scope. But that's another story.
HPACK is a stateful compression scheme designed specifically for HTTP headers. Its core idea has three parts:
Static Table (61 predefined entries):
RFC 7541 predefines 61 commonly used header field combinations:
Index 1 → :authority
Index 2 → :method = GET
Index 3 → :method = POST
Index 8 → :status = 200
Index 32 → cookieIf :method: GET happens to be at static table index 2, the entire request only needs to send one byte (the index) instead of 15 bytes of text.
Dynamic Table (maintained per connection session):
Each HTTP/2 connection maintains a FIFO dynamic table, defaulting to 4KB. The first time you send a custom header like x-custom-id: 42bc3f in a request, the server adds it to the dynamic table at index 62. The next time you send that header, you only need to send index 62 — one byte.
This shares a philosophy with TCP's sliding window: substitute a small amount of state information (index number) for full data, reducing transmission volume. TCP uses sequence numbers instead of retransmitting full data segments; HPACK uses indices instead of full header strings.
Huffman Encoding:
For header values, HPACK uses a static Huffman coding table. High-frequency characters (like lowercase letters) get shorter encodings, further compressing the volume.
Combined effect: a typical set of HTTP/1.1 request headers (~287 bytes) is compressed to roughly 32 bytes in HTTP/2 — a compression ratio approaching 90%. Moreover, this compression is stateful — the longer the connection stays alive, the more commonly used headers accumulate in the dynamic table, and the better subsequent requests compress.
Server push lets the server proactively send resources before the client explicitly requests them:
Client Server
|── GET /index.html ─────────────────→| Request HTML
|←─ PUSH_PROMISE (style.css) ────────| "I'll push style.css"
|←─ PUSH_PROMISE (app.js) ───────────| "I'll push app.js"
|←─ HEADERS (index.html) ────────────| Return HTML headers
|←─ DATA (index.html) ───────────────| Return HTML body
|←─ HEADERS (style.css) ─────────────| Push CSS headers
|←─ DATA (style.css) ────────────────| Push CSS body
|←─ HEADERS (app.js) ────────────────| Push JS headers
|←─ DATA (app.js) ───────────────────| Push JS bodyCompare to the traditional flow:
Traditional: HTML download → Parse → Discover CSS → Request CSS → Download CSS → Parse → ...
Push: HTML download + CSS download in parallelFrom TCP's perspective, push exploits the idle direction of a single TCP connection — while the server is transmitting HTML downstream, the same link has ample bandwidth to transmit CSS and JS in parallel. The client can disable push via SETTINGS_ENABLE_PUSH, or reject unwanted push resources by sending RST_STREAM.
Note: Server Push performed underwhelmingly in real-world HTTP/2 deployments — the browser might already have the resource cached, causing pushed data to waste bandwidth. Chrome dropped support for Server Push in 2022, and HTTP/3 removed it entirely. But it revealed an important idea: breaking the strict request-response binding, making communication more flexible.
curl --http2 -v https://example.com to make an HTTP/2 request and observe the ALPN (Application-Layer Protocol Negotiation) process.HTTP/2 uses binary framing and multiplexing to make HTTP run more efficiently over TCP — a single connection carrying all concurrent requests. But HTTP/2 still relies on the traditional request-response model. There's an entirely different communication paradigm we haven't explored yet: real-time, bidirectional communication.
What if the server could proactively push data to the client without waiting for a request? This is the problem WebSocket was built to solve — but before WebSocket became a standard, developers hacked together real-time features with clever HTTP tricks.
The next article traces the evolution from short polling to long polling — the predecessors of WebSocket — and explains exactly what problem WebSocket was designed to solve.