HTTP didn't evolve. It was forced to change by physics, latency, and misuse.
Every version exists because the previous one hit a hard constraint. If you understand those constraints, you understand why the web works the way it does - and why so many "best practices" are actually workarounds for protocol limitations we've been dragging around for thirty years.
I've been building web applications since 1997. I've debugged HTTP/1.0 connection storms, watched browsers open six connections per host as a "feature", cursed at HTTP/2's TCP head-of-line blocking on mobile networks, and finally seen HTTP/3 admit what we knew all along: the network is the problem.
This isn't a protocol tutorial. It's the story of how we got here.
HTTP/1.0 was designed in 1996 for a world that no longer exists. To understand why it worked, you need to understand what "the web" meant in 1996:
In this world, the bottleneck was bandwidth, not latency. A 50KB page took 15 seconds to download on dialup. Who cared about an extra 200ms handshake?
What HTTP/1.0 optimised for:
GET /index.html HTTP/1.0
Host: example.com
HTTP/1.0 200 OK
Content-Type: text/html
<html>...
Simple. Elegant. Perfect for 1996. And completely unusable for anything beyond document retrieval.
Then the web changed. Fast.
By 1998, pages weren't just documents. They had stylesheets, JavaScript, multiple images. A single page might need 20-30 separate resources.
Every single request required:
A page with 20 resources meant 20 TCP handshakes. On a 200ms latency connection (still common), that's 4 seconds of just handshaking before any content transferred.
The fundamental assumption of HTTP/1.0:
Time was cheap.
In 1996, it was. Bandwidth was the bottleneck. By 1999, bandwidth was improving but latency wasn't - and suddenly those round trips mattered.
HTTP/1.0 was designed for documents. The web had become an application platform. Something had to give.
HTTP/1.1 arrived in 1997, just as the web was exploding. The dotcom boom was starting. Every business needed a website. Web pages were getting complex - tables for layout, JavaScript for interactivity, images everywhere.
The pressure was clear: connection-per-request was killing performance. But completely redesigning HTTP wasn't an option. Too much infrastructure already depended on it. The solution had to be backward-compatible.
What changed:
Connection: keep-alive by default) - reuse the TCP connectionCache-Control, ETag, conditional requests)What didn't change:
GET /page.html HTTP/1.1
Host: example.com
Connection: keep-alive
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Cache-Control: max-age=3600
...
HTTP/1.1 didn't actually solve the performance problem. It just moved it around.
Pipelining was a failure. The spec allowed sending multiple requests on one connection, but:
So browsers cheated. Instead of fixing the protocol, they worked around it:
static1.example.com, static2.example.com) to multiply connectionsNone of this was the protocol working as designed. It was the entire ecosystem compensating for HTTP/1.1's fundamental limitation.
HTTP/1.1 scaled by cheating, not by fixing the model.
HTTP/1.1 worked well enough for fifteen years. Not because it was good, but because the environment compensated:
The protocol was still broken. We were just lucky the world was hiding it.
Those six connections per host weren't free:
The web got faster in the HTTP/1.1 era. But not because of HTTP/1.1. It got faster because:
We were building an application platform on a document retrieval protocol. The protocol was losing. We just didn't feel it yet.
By 2010, web performance was a constant battle against HTTP itself.
The "best practices" of the era tell the story:
Every single one of these is a workaround for "HTTP/1.1 can't handle many requests efficiently."
I built many of these little tools like sprite generators, CSS compressors, we started using response compression etc...
Browsers weren't faster because HTTP improved. They were faster because they worked around HTTP.
Six connections per host is not a feature. It's a hack. Domain sharding is not a best practice. It's an admission of failure.
I spent years teaching developers to bundle assets, sprite images, and inline critical resources. None of that was "good architecture." It was protocol damage control.
The "HTTP is simple" myth persisted because the complexity was hidden in:
HTTP/1.1 worked because everything around it worked overtime to compensate.
If you've ever seen a diagram of HTTP that doesn't show latency, loss, or failure modes, it's lying to you. The protocol is simple. The reality isn't.
By 2010, the web had transformed again. Two massive shifts changed everything:
1. Mobile happened. The iPhone launched in 2007. By 2012, mobile web traffic was exploding. Suddenly users weren't on wired broadband - they were on 3G, then 4G, with variable latency and frequent packet loss. The assumptions that made HTTP/1.1 tolerable were breaking down.
2. Web applications replaced web pages. Gmail. Google Maps. Facebook. Twitter. These weren't documents with links. They were applications that needed dozens or hundreds of resources, real-time updates, and instant response. The "six connections per host" hack was showing its age.
Google felt this pain acutely. They had massive scale, performance-obsessed engineers, and the data to prove HTTP/1.1 was the bottleneck. In 2009, they started SPDY.
SPDY (pronounced "speedy") was Google's answer to HTTP/1.1's limitations. It wasn't a standards effort - it was Google shipping code and seeing what worked.
What SPDY introduced:
Google deployed SPDY across Chrome and their own services. By 2012, Gmail, Google Search, and YouTube were all running SPDY. The results were compelling: 40-60% latency reduction in some cases.
The path to HTTP/2:
SPDY proved the concepts worked. The IETF took notice and used SPDY as the starting point for HTTP/2. The final HTTP/2 spec (2015) isn't identical to SPDY - header compression changed significantly (HPACK replaced SPDY's zlib-based approach after security concerns), and various details were refined - but the architecture is recognisably SPDY's.
Google deprecated SPDY in 2016 once HTTP/2 had sufficient adoption. Mission accomplished: they'd forced the web forward by shipping first and standardising later.
What HTTP/2 changed (building on SPDY):
┌──────────────────────────────────────────┐
│ Single TCP Connection │
├──────────────────────────────────────────┤
│ Stream 1: GET /page.html │
│ Stream 3: GET /style.css │
│ Stream 5: GET /app.js │
│ Stream 7: GET /logo.png │
│ (all interleaved, no ordering required) │
└──────────────────────────────────────────┘
This is what HTTP/1.1 pipelining should have been. Streams are independent. One slow response doesn't block others. Headers are compressed. The protocol finally matched how we actually use the web.
HTTP/2 was perfect for the broadband web. And in 2015, when it standardised, broadband was ubiquitous in the developed world.
What improved:
For users on wired connections with low packet loss, HTTP/2 was a genuine improvement. The benchmarks looked great. The metrics improved. Google declared victory.
But the world had already moved on. Mobile was now the majority of web traffic.
And mobile networks have a fundamental property that broadband doesn't: packet loss is common and unpredictable.
TCP guarantees in-order delivery. If packet 47 is lost, packets 48-100 wait until 47 is retransmitted - even if they belong to completely independent HTTP/2 streams.
┌──────────────────────────────────────────┐
│ TCP Receive Buffer │
├──────────────────────────────────────────┤
│ [pkt 45][pkt 46][ ? ][pkt 48][pkt 49] │
│ ↑ │
│ Waiting for packet 47 │
│ │
│ Stream 1 data: BLOCKED │
│ Stream 3 data: BLOCKED │
│ Stream 5 data: BLOCKED (has pkt 48-49) │
│ Stream 7 data: BLOCKED │
└──────────────────────────────────────────┘
HTTP/2 solved head-of-line blocking at the application layer. Then TCP reintroduced it at the transport layer.
One lost packet stalls all streams. On a clean wired connection, packet loss is rare. On mobile networks, lossy WiFi, or congested links? Packet loss is constant. And because HTTP/2 uses a single connection (by design), one lost packet now blocks everything instead of just one of six connections.
HTTP/2 performance:
The irony: HTTP/2 was meant to fix the web's multiplexing problem - just as mobile made loss impossible to ignore. TCP's guarantees made it worse on mobile than the protocol it replaced.
Initial HTTP/2 deployments showed real improvements:
All those tools I built for 1.1? The all went away with HTTP/2, suddenly bundling was a BAD thing. HTTP/2's multiplexing was great for assets on the same origin, but it didn't help with cross-origin requests. We had to build new tools to bundle those (webpack on this site for example!).
But tail latency told a different story. The median got better. The P99 got worse.
I saw this firsthand: a client enabled HTTP/2 across their CDN and watched mobile P99 latency increase by 40%. The dashboards showed improvement on desktop. The support tickets came from mobile users. We spent a week figuring out that HTTP/2's "improvement" was making things worse for the majority of their traffic.
When HTTP/2 works, it works beautifully. When a packet drops on a mobile connection at the worst moment, everything freezes. And users notice freezes more than they notice fast medians.
The fundamental problem:
HTTP/2 assumed TCP was good enough.
For broadband, it was. For mobile, it wasn't. And by the time HTTP/2 was standardised, mobile was already winning. We'd pushed as far as TCP could take us.
By 2015, the evidence was clear: TCP was the problem.
Google had been running QUIC experimentally since 2012. They had the data. On mobile networks, lossy WiFi, and high-latency connections, HTTP/2 over TCP was failing in ways that couldn't be fixed without changing the transport layer.
But you can't just "fix TCP." It's implemented in operating system kernels. It's baked into every router, firewall, and middlebox on the internet. Changing TCP means waiting decades for the entire internet infrastructure to upgrade.
So Google did something audacious: they built a new transport protocol on top of UDP.
QUIC (originally "Quick UDP Internet Connections", now just QUIC) is a transport protocol that runs over UDP but provides TCP-like reliability. The key insight: implement reliability in userspace, where you can iterate fast.
Why UDP as a foundation?
UDP is deliberately minimal. It adds port numbers to IP and... that's basically it. No handshakes, no reliability, no ordering. Packets might arrive out of order, duplicated, or not at all. UDP doesn't care.
This "dumbness" is exactly what QUIC needed. By building on UDP, QUIC could:
What QUIC does differently:
| Concern | TCP | QUIC |
|---|---|---|
| Reliability unit | Connection | Stream |
| Handshake | TCP + TLS separate | Combined (faster) |
| Encryption | Optional (TLS) | Mandatory (built-in) |
| Head-of-line blocking | All data blocked | Only affected stream |
| Connection identity | IP + Port | Connection ID |
| Implementation | Kernel | Userspace |
The connection ID is particularly clever. TCP identifies connections by IP address and port. Change either (switch WiFi networks, NAT rebinding) and the connection dies. QUIC uses a connection ID that survives network changes.
The path to HTTP/3:
Just like SPDY led to HTTP/2, Google's QUIC experiment led to HTTP/3. The IETF standardised QUIC in 2021 (RFC 9000), and HTTP/3 (RFC 9114) is simply HTTP semantics over QUIC instead of TCP.
The standardised QUIC differs from Google's original in some details (the crypto handshake was reworked, some header formats changed), but the core architecture - streams with independent loss recovery over UDP - remained.
HTTP/3 (2022) is HTTP over QUIC. It's the admission that we needed to go below HTTP to fix HTTP.
What HTTP/3 changed:
┌──────────────────────────────────────────┐
│ QUIC Connection │
├──────────────────────────────────────────┤
│ Stream 1: [pkt][pkt][pkt] ← flowing │
│ Stream 3: [pkt][ ? ][pkt] ← waiting │
│ Stream 5: [pkt][pkt][pkt] ← flowing │
│ Stream 7: [pkt][pkt] ← flowing │
│ │
│ Lost packet only affects Stream 3 │
└──────────────────────────────────────────┘
What stayed the same:
HTTP/3 didn't change HTTP. It changed how HTTP survives reality.
QUIC implements TCP's reliability guarantees per stream, not per connection. This is the key insight that fixes HTTP/2's fatal flaw.
TCP's promise: "Every byte arrives in order." QUIC's promise: "Every byte in this stream arrives in order."
That distinction is why HTTP/3 doesn't collapse on lossy networks. Let me unpack how this actually works.
Stream independence:
In QUIC, each HTTP request/response pair gets its own stream. Streams are logically independent - they share a connection for efficiency, but packet loss on one stream doesn't block others.
QUIC Connection
├── Stream 1: GET /index.html [packets 1, 2, 3]
├── Stream 3: GET /style.css [packets 4, 5] ← packet 5 lost
├── Stream 5: GET /app.js [packets 6, 7, 8]
└── Stream 7: GET /logo.png [packets 9, 10]
Packet 5 is retransmitted. Only Stream 3 waits.
Streams 1, 5, 7 continue unaffected.
Compare this to HTTP/2 over TCP: all those streams share one TCP byte sequence. Lose one packet, everything waits.
Connection migration:
TCP identifies connections by a 4-tuple: source IP, source port, destination IP, destination port. Change any of these and the connection is dead.
Phone on WiFi → walks out of range → switches to cellular
TCP: Source IP changed. Connection dead. Full reconnect.
QUIC: Connection ID unchanged. Streams continue seamlessly.
This matters because mobile users constantly switch networks. Walk out of your house, lose WiFi, switch to cellular. With TCP, every connection dies. With QUIC, you might not even notice.
0-RTT resumption:
Traditional TCP + TLS requires multiple round trips before sending data:
QUIC combines the transport and crypto handshakes. First connection takes 1 RTT. But for returning users with cached credentials, QUIC offers 0-RTT: send data immediately with your first packet.
First visit: Client ──[handshake]──> Server ──[response]──> Client (1 RTT)
Return visit: Client ──[data]──────────────────[response]──> Client (0 RTT)
0-RTT has security implications (replay attacks are possible), so it's limited to idempotent requests. But for most page loads, it's a significant latency win.
Encryption as a first-class citizen:
TCP was designed in the 1970s. Encryption was bolted on later via TLS. This means:
QUIC encrypts almost everything from the start. Even the packet numbers are encrypted. This isn't just for privacy - it prevents the ossification problem where middleboxes depend on being able to read headers, making protocol evolution impossible.
TCP packet: [IP header][TCP header (plaintext)][TLS encrypted payload]
QUIC packet: [IP header][UDP header][QUIC header (mostly encrypted)][encrypted payload]
HTTP/3 is designed for the world we actually live in:
The protocol finally matches the network reality. Thirty years after HTTP/1.0 assumed reliable wired connections, HTTP/3 acknowledges that reliability is the exception, not the rule.
HTTP/3 isn't free:
But for mobile-first applications, unreliable networks, and latency-sensitive services, HTTP/3 is the first version that matches how networks actually behave.
Every HTTP version change happened because the environment changed faster than the protocol.
| Version | Era | Environment | Assumption | Why It Broke |
|---|---|---|---|---|
| HTTP/1.0 | 1996 | Dialup, documents | Bandwidth is the bottleneck | Pages became apps, latency mattered |
| HTTP/1.1 | 1999-2015 | Broadband, web apps | Low latency hides inefficiency | Mobile arrived with packet loss |
| HTTP/2 | 2015-2022 | Broadband peak, mobile rising | TCP is reliable enough | Mobile became dominant, TCP failed |
| HTTP/3 | 2022+ | Mobile-first, global | Nothing is reliable | Still deploying... |
The pattern is clear:
The protocol keeps getting pushed closer to the network. Each abstraction that seemed sufficient got peeled back when the environment demanded it.
Other observations:
Here's the uncomfortable part. Despite thirty years of protocol evolution:
Requests still fail. Networks partition. Servers crash. Timeouts happen. Your code still needs retry logic.
Networks still lie. 200 OK doesn't mean the response is correct. Connection closed doesn't mean the request didn't succeed. Timeouts don't mean the server didn't process your request.
Caches still serve stale data.
Cache-Control is a request, not a command. Proxies do what they want. CDN invalidation is eventual at best.
Clients still retry badly. User clicks button, nothing happens, clicks again. Now you have two requests. Idempotency matters.
Developers still misunderstand idempotency. GET should be safe. PUT should be idempotent. POST is the wild west. Most APIs get this wrong.
// This is still your problem, regardless of HTTP version
public async Task<Result> CreateOrderAsync(Order order)
{
// What happens if this times out?
// Did the server receive it?
// Did it process it?
// Will the client retry?
// Will you create duplicate orders?
// HTTP/3 doesn't save you here.
// Idempotency keys do.
}
If you don't understand these, HTTP/3 won't save you.
After building web applications across all these protocol versions, here's what stuck:
1. The protocol is not your reliability layer.
HTTP gives you request/response semantics. It doesn't guarantee delivery, correctness, or exactly-once processing. That's your job.
2. Latency is the enemy, not bandwidth.
Most performance problems are round-trip bound, not throughput bound. Reducing requests matters more than compressing payloads.
3. Every "best practice" has an expiration date.
Domain sharding was essential for HTTP/1.1. It's actively harmful for HTTP/2 (breaks the single-connection benefit). "Bundle everything into one file" was gospel; now shipping many small modules over HTTP/2 or HTTP/3 is often better. The tactics change. The goal (reduce latency) stays the same.
4. Measure on real networks.
Benchmarks on localhost prove nothing. Your users are on mobile networks, hotel WiFi, and saturated coffee shop connections. Test there.
5. The boring parts matter most.
Timeouts. Retries. Circuit breakers. Idempotency. Connection pooling. These unglamorous details determine whether your system works under load.
HTTP didn't get faster because it got smarter. It got faster because we finally admitted the network was the problem.
Each version was right for its time:
The mistake was thinking any of them were permanent solutions. They were all responses to specific environmental pressures. When the environment changed, the protocol had to follow.
HTTP versions are not upgrades in the consumer sense. They are tradeoffs optimised for different failure distributions.
And still, after all of that, the fundamentals remain:
The protocol evolved. The problems didn't disappear. They just moved.
Build systems that survive failures. Test on real networks. Understand that every HTTP version is a tradeoff shaped by its era, not an upgrade that obsoletes what came before.
That's thirty years of HTTP. That's the web we built. And in another decade, HTTP/3's assumptions will probably look as dated as HTTP/1.0's do now.
© 2025 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.