Synchronous Communication

System Design · Synchronous Communication

HTTP Deep Dive: Versions & Semantics

Why HTTP is stateless, why HTTP/1.1 stalls under load, and how HTTP/2 and HTTP/3 each rebuilt the transport underneath it to fix that.

9 min read
HTTPHTTP/2HTTP/3

HTTP is the protocol almost every distributed system eventually speaks at its edges, yet it's easy to use for years without ever asking what it actually guarantees. It guarantees very little: a client sends a request, a server sends back a response, and the connection between them has no memory of anything that happened before. Understanding that one design choice — and the two very different ways the protocol has since evolved its transport — explains most of the "why is HTTP built this way" questions that come up in system design.

Request/response, and nothing else

Every HTTP request is a self-contained package: a method, a path, a set of headers, and optionally a body. The server reads it, produces a response, and forgets it ever happened. There is no built-in concept of a "session" or a "logged-in user" at the protocol level — HTTP is stateless by design.

That statelessness is a feature, not an oversight. It's what lets any server behind a load balancer handle any request, since no server needs to have seen the client before. But real applications do need state — a shopping cart, a logged-in identity, a multi-step checkout — so that state has to be layered on top rather than relied on from the protocol. Cookies let the server hand the client a token to echo back on every subsequent request; bearer tokens and API keys do the same thing explicitly in the Authorization header. The state lives in the token or in a database the server looks up by that token's value — never in the connection itself.

http
GET /orders/8841 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...
Accept: application/json

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=60
ETag: "a1b2c3"

{ "id": 8841, "status": "shipped" }

HTTP/1.1's bottleneck: one request at a time

HTTP/1.1 sends requests as plain text over a TCP connection, and it can only have one request in flight per connection at a time — the server must finish writing the full response before the client can send the next request down that same connection. If a page needs thirty assets, a single connection would fetch them one after another, serially, no matter how fast the network is.

Browsers worked around this by opening several TCP connections to the same host in parallel — typically six — trading one slow connection for several moderately busy ones. HTTP/1.1 also defined pipelining, which let a client fire off several requests without waiting for each response, but the responses still had to come back in the same order they were requested. One slow response at the front of the queue still blocked everything behind it — a problem known as head-of-line blocking — and enough servers and proxies handled pipelining incorrectly that browsers quietly disabled it. Both workarounds treated a structural limitation of the protocol as something to route around rather than fix.

HTTP/2: multiplexing over one connection

HTTP/2 fixed the actual problem instead of working around it. It replaced HTTP/1.1's plain-text request lines with a binary framing layer: every request and response is broken into small frames, each tagged with a stream ID, and frames from many streams can be interleaved over a single TCP connection. The server can start streaming the response to request 3 before it's even finished responding to request 1 — true multiplexing, all on one connection, with no need for the six-connections-per-host trick.

Two more changes came along with multiplexing. HPACK header compression maintains a shared table of previously sent headers on both ends of the connection, so repetitive headers (cookies, user-agent strings, auth tokens) are sent as small references instead of full text on every request — a meaningful saving when a page fires off dozens of requests carrying nearly identical headers. Server push let a server proactively send resources it knew the client would need next, without waiting for a request. In practice, push turned out to be hard to cache correctly and easy to waste bandwidth with, and browsers have since dropped support for it — a reminder that not every protocol feature that sounds good on paper survives contact with production traffic.

Multiplexing fixes application-level blocking, not transport-level blocking

HTTP/2's multiplexing happens above TCP. TCP still delivers bytes to the application in strict order, so if a single packet is lost, TCP holds back every stream's data that arrived after it — even streams that have nothing to do with the lost packet — until the retransmission arrives. HTTP/2 removed head-of-line blocking at the HTTP layer, but reintroduced a version of it one layer down, at the TCP layer.

HTTP/3: multiplexing that survives packet loss

HTTP/3's defining change isn't really about HTTP at all — it's about the transport underneath it. HTTP/3 drops TCP entirely and runs over QUIC, a transport protocol built on top of UDP. That sounds like a step backwards — UDP has no built-in reliability or ordering — but QUIC implements reliability itself, per stream, at the application layer rather than inheriting TCP's single global ordering guarantee.

The payoff is that a lost packet belonging to stream A no longer blocks delivery of already-arrived data on stream B — QUIC's streams are independently ordered, so HTTP/3 finally eliminates head-of-line blocking at every layer, not just the HTTP layer HTTP/2 fixed. QUIC also folds the TCP handshake and the TLS handshake into a single round trip, and for a server the client has connected to before, it can send encrypted application data on the very first packet — 0-RTT — instead of waiting for a handshake to complete at all. On a lossy or high-latency connection, like a mobile network switching cell towers, that difference is the gap between a page that stalls and one that doesn't.

This is also the point where HTTP's history quietly hands off to a different question entirely: why would anyone build a reliable transport on top of UDP instead of just fixing TCP? The short answer is that TCP's ordering and congestion-control logic live in the operating system kernel, which makes them slow to evolve and impossible for an application to customize — UDP gives QUIC a blank slate to implement reliability exactly the way HTTP needs it. That trade-off between TCP and UDP is worth its own treatment, and is exactly what the next article in this series digs into.

Idempotency: what's safe to retry

None of this — multiplexing, QUIC, 0-RTT — helps if a client can't tell whether it's safe to retry a failed request, so HTTP methods carry two separate guarantees worth keeping straight. Safe methods don't change server state at all —GET and HEAD are read-only by definition. Idempotent methods may change state, but calling them once has the same effect as calling them ten times: PUT (replace this resource with exactly this representation) and DELETE (this resource should not exist) both qualify, and every safe method is trivially idempotent too.

POST is neither. A POST to /orders typically means "create a new one," and retrying it after a timeout risks creating a duplicate order — the client genuinely cannot tell whether the original request failed before or after the server processed it. This is precisely why a client library, load balancer, or proxy can safely auto-retry a failed GET or PUT without asking anyone, but must never auto-retry a POST without an idempotency key the server can use to recognize and dedupe a repeated attempt.

Status codes and caching, briefly

Status codes group into five families: 1xx (informational, rare in practice —100 Continue), 2xx (success — 200 OK, 201 Created, 204 No Content), 3xx (redirection — 301/308 for permanent moves, 304 Not Modified for a cache hit), 4xx (the client's request is the problem — 400, 401, 403, 404, 429), and 5xx (the server's problem — 500, 502, 503). A client that branches on the numeric family rather than memorizing every code handles ones it's never seen correctly by default.

Caching headers ride along on the same response. Cache-Control: max-age=60 tells a client or proxy it can reuse the response for 60 seconds without asking again, while an ETag gives it a fingerprint to send back on the next request — if the resource hasn't changed, the server replies with a bare 304 Not Modified instead of resending the whole body. Together they turn "fetch it again" into "ask if it changed," which is often the cheapest optimization available in the entire stack.

Key takeaways

  • HTTP is stateless by design — cookies, tokens, and server-side sessions all exist purely to layer state on top of a protocol that has none.
  • HTTP/1.1 allows only one request in flight per connection; multiple connections and pipelining were workarounds, not fixes, for the head-of-line blocking that causes.
  • HTTP/2 fixes it with binary framing and multiplexing over one TCP connection, plus HPACK header compression; server push looked good on paper but didn't survive production.
  • HTTP/3 runs over QUIC on UDP instead of TCP, eliminating head-of-line blocking at the transport layer too and enabling 0-RTT connection setup.
  • GET, HEAD, PUT, and DELETE are idempotent and safe to auto-retry; POST is not, and needs an idempotency key to retry safely.
  • Status code families (1xx-5xx) plus Cache-Control/ETag headers let clients and proxies avoid re-fetching unchanged data entirely.
2026 — Built by Arpit Mishra