HTTP 1.1, 2, and 3: what broke and what fixed it
Each HTTP version exists because the previous one had a fatal flaw. Follow the chain: what broke, what they tried, what actually worked.
HTTP/1.0: one request, one connection
HTTP/1.0 opened a new TCP connection for every single request. Load a page with 30 images? That's 30 TCP handshakes. Each handshake costs 1 RTT (round trip time). The page spent more time shaking hands than transferring data.
RTT matters throughout this article. One RTT = one packet from client to server, plus one packet back. Go and return. You're measuring how long you're stuck waiting, not how many packets flew.
client → server half trip
server → client half trip
together = 1 RTT
A TCP three-way handshake has three packets but costs only 1 RTT:
client → server: SYN
server → client: SYN-ACK ← 1 RTT of waiting ends here
client → server: ACK + data ← piggybacks on next data, no extra wait
The third packet doesn't block anything. Client sends it and starts transmitting data immediately. Three packets, one round trip of idle time.
HTTP/1.1: keep the connection, but wait your turn
HTTP/1.1 fixed the connection problem. Connection: Keep-Alive let one TCP connection handle multiple requests. No more handshake per image.
But it introduced a new problem: head-of-line blocking (HOLB).
HTTP/1.1 had "pipelining" — send multiple requests without waiting for responses. Sounds great. Except responses had to come back in order. If the first response was slow (big image, slow database query), every response behind it waited.
Request: [A] [B] [C] →
Response: [A......] [B] [C]
↑
B and C wait for A
Pipelining was disabled by default in every major browser. Nobody trusted it. In practice, HTTP/1.1 was still one-request-one-response on each connection.
Browsers worked around this by opening 6 parallel connections per origin (Chrome, Firefox, Edge all cap at 6). Each domain counted independently — a page loading from your-site.com + 3 CDN domains could run 24 connections in parallel. Some sites exploited this by spreading resources across subdomains (img1.example.com, img2.example.com) — called domain sharding. A hack, but it worked well enough for a decade.
What else 1.1 added
| Feature | What it did |
|---|---|
Host header | Enabled virtual hosting — multiple domains on one IP |
Cache-Control, ETag | Replaced 1.0's primitive Expires / If-Modified-Since |
| Chunked transfer | Stream responses before knowing total size (image loading in pieces) |
| Range requests | Resume interrupted downloads (Range: bytes=0-499) |
| Content negotiation | Accept / Content-Type headers |
HTTP/2: one connection, many streams
Google built SPDY in 2009 to prove that HTTP could be faster without changing the semantics. In 2015, IETF standardized it as HTTP/2.
The core idea: multiplexing. One TCP connection carries multiple "streams." Each stream is an independent request-response pair. Streams interleave freely. No more waiting in line.
HTTP/1.1: [A......][B..][C....] ← sequential
HTTP/2: [A..[B.][A..][C..][B][A] ← interleaved
A single HTTP message splits into frames — the smallest transmission unit. Frames carry a stream ID so the receiver can reassemble them. Streams are logical. Frames are physical. A single TCP segment might carry frames from three different streams.
TCP segment 1: [frame a2] [frame b1] [frame c1]
TCP segment 2: [frame b2] [frame b3]
TCP segment 3: [frame a1] [frame b4] [frame b5]
The receiver uses stream IDs to reconstruct each request. Order within a stream is guaranteed. Order across streams is not — and that's the point.
Won't one connection get congested? No. 12 TCP connections sharing 100 Mbps is the same bandwidth as 1 TCP connection using 100 Mbps — minus 11 handshakes. Domain sharding became an anti-pattern. More connections meant more wasted handshakes for no bandwidth gain.
Header compression (HPACK)
HTTP/1.1 compressed response bodies (Content-Encoding: gzip) but sent headers as plain text. Every request repeated the same headers — User-Agent, Cookie, Accept. On a page with 50 requests, that's 50 copies of the same 2KB cookie.
HTTP/2 introduced HPACK: a static table of 61 common header entries (:method GET, :path /, etc.) plus a dynamic table that remembers recently sent headers. Second request with the same Cookie? Send index 63 instead of the full value.
Static table (built-in):
Index 2 → :method GET
Index 4 → :path /
Dynamic table (per-connection):
Index 62 → user-agent Mozilla/5.0 ...
Index 63 → cookie session=abc123
Server Push (the promise that didn't deliver)
HTTP/2 let servers push resources before the client asked. Request index.html, server pushes style.css and app.js along with it.
In theory, this eliminates round trips for known dependencies. In practice, only 1.25% of HTTP/2 sites ever used it. The server often pushed resources the browser already had cached — a net performance regression. Chrome 106 (2022) disabled it by default. The replacement is 103 Early Hints: instead of pushing resources, the server hints which resources the browser should fetch. The browser decides whether it needs them. Less aggressive, fewer mistakes.
ALPN: negotiating the protocol for free
How does the browser tell the server it wants HTTP/2? Through ALPN (Application-Layer Protocol Negotiation), a TLS extension. During the TLS handshake, the client includes a list of supported protocols (h2, http/1.1). The server picks one and responds. No extra round trip.
ALPN replaced NPN (Next Protocol Negotiation), which came from SPDY. SPDY became HTTP/2. NPN became ALPN.
Browsers only support HTTP/2 over TLS (h2). The spec allows cleartext HTTP/2 (h2c via the Upgrade header), but no browser implements it. In practice, HTTP/2 means HTTPS.
The flaw HTTP/2 couldn't fix
HTTP/2 solved head-of-line blocking at the application layer. But TCP has its own HOLB.
TCP guarantees ordered, reliable delivery. If one packet is lost, TCP stalls the entire connection until that packet is retransmitted and arrives. All streams — even those whose packets arrived fine — wait.
Stream A: [packet 1] [packet 2] [packet 3 LOST] [packet 4]
Stream B: [packet 1] [packet 2] ← waiting for A's packet 3
Stream C: [packet 1] ← also waiting
HTTP/2 multiplexes streams over one connection. One lost packet blocks every stream. The more streams you have, the worse it gets. On lossy networks (mobile, Wi-Fi), HTTP/2 can be slower than HTTP/1.1's six parallel connections.
HTTP/3: replace TCP entirely
TCP's HOLB couldn't be fixed without changing TCP. So Google built QUIC — a transport protocol on top of UDP that reimplements TCP's reliability without its head-of-line blocking.
QUIC is not "just UDP." It adds everything TCP provides — congestion control, loss recovery, ordered delivery — but per stream. A lost packet in stream A doesn't block streams B and C.
What QUIC changes
| Problem in TCP | QUIC solution |
|---|---|
| One lost packet blocks all streams | Independent stream loss recovery |
| TCP handshake + TLS handshake = 2-3 RTT | QUIC merges both into 1 RTT |
| IP change = connection dies (Wi-Fi → 4G) | Connection migration via connection IDs |
| Ossified by middleboxes | Encrypted transport headers, harder to interfere |
RTT comparison
This is the real story of HTTP's evolution — fewer round trips to first byte.
| Scenario | TCP | TLS | Request | Total |
|---|---|---|---|---|
| HTTP/1.1 (no TLS) | 1 | 0 | 1 | 2 RTT |
| HTTP/2 + TLS 1.2 | 1 | 2 | 1 | 4 RTT |
| HTTP/2 + TLS 1.3 | 1 | 1 | 1 | 3 RTT |
| HTTP/2 + TLS 1.3 session resumption | 1 | 0 | 1 | 2 RTT |
| HTTP/3 (QUIC) first connection | — | 1 (merged) | 1 | 2 RTT |
| HTTP/3 (QUIC) session resumption | — | 0 | 1 | 1 RTT |
QUIC's "0-RTT" doesn't mean zero round trips. It means the TLS handshake costs zero additional RTT because the client sends encrypted application data in its very first packet, using keys from a previous session. The server can respond immediately. Total: 1 RTT for the entire exchange.
HTTP/2 with TLS 1.3 session resumption also skips the TLS handshake — but still needs TCP's 1 RTT handshake first. QUIC merges TCP + TLS, so it saves that extra trip.
TLS 1.2 vs 1.3
| TLS 1.2 | TLS 1.3 | |
|---|---|---|
| Handshake | 2 RTT | 1 RTT |
| Session resumption | 1 RTT | 0 RTT (send data with first packet) |
| Key exchange during ClientHello | No (separate step) | Yes (included) |
TLS 1.3 sends the key share in ClientHello. The server has everything it needs to respond in one shot. TLS 1.2 needed a second round trip for key exchange.
The chain
Each version fixed the previous version's fatal flaw. Each fix revealed the next bottleneck.
HTTP/1.0 → new TCP connection per request (slow)
↓ fix: persistent connections
HTTP/1.1 → responses must return in order (HOLB)
↓ fix: multiplexed streams over one connection
HTTP/2 → TCP HOLB blocks all streams on packet loss
↓ fix: replace TCP with QUIC (UDP + per-stream recovery)
HTTP/3 → 1 RTT first connection, 0-RTT resumption
That's the whole story. Not three separate protocols — one protocol that kept hitting walls and tearing them down.
References
- HTTP/2: A New Excerpt from High Performance Browser Networking (ACM Queue)
- Why HTTP/3 Is Eating the World (APNIC Blog)
- HTTP/3 Performance Improvements Part 2 (Smashing Magazine)
- Head-of-Line Blocking in HTTP/1 and HTTP/2 (CRED Engineering)
- HTTP/1.1 Pipelining vs HTTP/2 Multiplexing (StackOverflow)
- HTTP/2 Frames and TCP Segments relationship (StackOverflow)
- Removing HTTP/2 Server Push from Chrome (Chrome Developers Blog)
- 103 Early Hints (Chrome Developers)