HTTP 1.1, 2, and 3: what broke and what fixed it

networkhttpquictls

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

FeatureWhat it did
Host headerEnabled virtual hosting — multiple domains on one IP
Cache-Control, ETagReplaced 1.0's primitive Expires / If-Modified-Since
Chunked transferStream responses before knowing total size (image loading in pieces)
Range requestsResume interrupted downloads (Range: bytes=0-499)
Content negotiationAccept / 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 TCPQUIC solution
One lost packet blocks all streamsIndependent stream loss recovery
TCP handshake + TLS handshake = 2-3 RTTQUIC merges both into 1 RTT
IP change = connection dies (Wi-Fi → 4G)Connection migration via connection IDs
Ossified by middleboxesEncrypted transport headers, harder to interfere

RTT comparison

This is the real story of HTTP's evolution — fewer round trips to first byte.

ScenarioTCPTLSRequestTotal
HTTP/1.1 (no TLS)1012 RTT
HTTP/2 + TLS 1.21214 RTT
HTTP/2 + TLS 1.31113 RTT
HTTP/2 + TLS 1.3 session resumption1012 RTT
HTTP/3 (QUIC) first connection1 (merged)12 RTT
HTTP/3 (QUIC) session resumption011 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.2TLS 1.3
Handshake2 RTT1 RTT
Session resumption1 RTT0 RTT (send data with first packet)
Key exchange during ClientHelloNo (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