HTTP/1.1, HTTP/2 and Multiplexing

Topics Covered

HTTP/1.1 and Its Limits

The shape of the protocol

Persistent connections, and the one thing they do not fix

Pipelining, and why it failed

Parallel connections, and the number six

Domain sharding, the hack that became an anti-pattern

Bodies, lengths and chunked transfer

Connection close, and the half-second nobody notices

What HTTP/1.1 still does better

HTTP/1.1 is still the protocol most backend code is written against, and understanding exactly where it runs out of room is what makes the design of everything after it legible. The limits are not about expressiveness, they are about concurrency on a single connection.

The shape of the protocol

An HTTP/1.1 exchange is a request followed by a response, both framed as text: a start line, a set of header lines, a blank line, and an optional body. The server must send responses in the order the requests arrived, and nothing else may be in flight on that connection in the meantime.

That last property is the whole constraint. A connection carries one exchange at a time, so the concurrency available to a client is exactly the number of connections it is willing to open. Everything that follows, including HTTP/2's existence, is a consequence of trying to raise concurrency without raising connection count.

The text framing has its own costs, which are smaller but real. Parsing is more expensive than parsing a binary format, the rules for line endings and whitespace have historically been a source of security bugs, and header names and values are sent in full on every request even when they are identical to the previous one, which for a request with large cookies can mean more header bytes than body bytes.

Persistent connections, and the one thing they do not fix

HTTP/1.0 closed the connection after each response, so every request paid a TCP handshake. HTTP/1.1 made persistence the default: the connection stays open and subsequent requests reuse it, which removes the handshake from all but the first exchange.

This is the single largest performance property of HTTP/1.1 and the one most often broken by accident. A client that constructs a new connection per request, usually because the HTTP client object is created inside the request handler, pays a handshake and a TLS handshake every time and sees latency several times higher than necessary. The server side has its own version: an idle timeout shorter than the client's means the server closes connections the client still believes are good, producing retries and occasional errors.

What persistence does not fix is concurrency. The connection is still serially used, so a client wanting four requests in parallel still needs four connections.

Pipelining, and why it failed

The specification does allow pipelining: sending several requests without waiting for the responses. In principle this removes the per-request round trip, and in practice it was never usable.

The reason is that responses must still come back in request order, so one slow response blocks every response queued behind it. That is head-of-line blocking at the application layer, and it makes pipelining a performance risk rather than a win: a client cannot know which request will be slow, so the expected outcome of pipelining is worse than sequential requests whenever the ordering is unlucky.

The secondary reason is that intermediaries handled it badly. Proxies that did not implement it correctly would reorder, drop or mis-associate responses, which is a correctness failure rather than a performance one, so clients disabled it. Every major browser shipped it off by default and eventually removed it.

Pipelining removes the request round trips and keeps the ordering constraint, so one slow response blocks everything behind it. Opening more connections was the workaround the web actually used.

Parallel connections, and the number six

The workaround the web settled on is to open several connections per host, and browsers converged on a limit of six. The number is a compromise: more connections mean more concurrency and also more handshakes, more server sockets, and more independent congestion control states competing on the same path.

That last point is worth dwelling on. Six TCP connections to the same server each run their own congestion controller, each probing for capacity independently, which means they collectively behave more aggressively than one connection and interact badly with each other on a constrained path. The aggregate is unfair to other traffic and the individual connections see more loss than they would otherwise.

For a backend service calling another service the equivalent decision is the connection pool size, and the same trade applies: a larger pool buys concurrency and costs sockets, memory and handshakes on both sides.

Domain sharding, the hack that became an anti-pattern

Because the six-connection limit is per host, serving assets from several hostnames multiplies it, and for years domain sharding was standard advice: static1.example.com through static4.example.com giving twenty-four parallel connections.

It worked and it was expensive. Each hostname costs a DNS lookup, a TCP handshake and a TLS handshake, and the connections cannot share a congestion window or a compression context. Under HTTP/2 it is actively harmful, since the protocol multiplexes on one connection and sharding splits that back into several, which is why removing sharding is part of every HTTP/2 migration.

The general lesson is that optimisations built around a protocol limit become pessimisations when the limit is removed, and they persist in codebases and CDN configurations long after that.

Bodies, lengths and chunked transfer

A recipient needs to know where a message body ends, and HTTP/1.1 offers two mechanisms, which is one more than ideal.

Content-Length states the size up front, which requires the sender to know it, so a dynamically generated response must either be buffered entirely or use the alternative. Chunked transfer encoding sends a series of length-prefixed chunks terminated by a zero-length chunk, which allows streaming without knowing the total size.

Having two mechanisms created a long-running security problem: if a message carries both, and two servers in a chain disagree about which to honour, an attacker can construct a request that one server sees as one message and the next sees as two. That is request smuggling, and it has produced serious vulnerabilities in widely deployed proxies. The defence is strict rejection of ambiguous messages, which modern servers implement and which older ones in the chain may not.

Connection close, and the half-second nobody notices

Closing an HTTP/1.1 connection cleanly requires the Connection: close header or a mutual idle timeout, and the edge cases around it produce a recognisable class of intermittent error.

The race is this: a server decides to close an idle connection at the same moment a client decides to send a request on it. The client's request arrives after the server has closed, and the client sees a connection reset on a request it had every reason to believe would work. This is why HTTP clients retry idempotent requests on a connection-level failure, and why a server idle timeout should be longer than any client's.

The same race explains a persistent pattern in metrics: a low but nonzero rate of connection resets that correlates with traffic troughs rather than peaks, because a connection is only idle long enough to be reaped when traffic is light.

What HTTP/1.1 still does better

It would be wrong to leave this section implying HTTP/1.1 is simply obsolete, because two of its properties are genuine advantages and they explain why it is still the right choice for some paths.

The first is independence. Several connections fail independently, recover independently and are load balanced independently, so a connection-level fault takes out one request rather than all of them, and a layer 4 balancer distributes them evenly without any awareness of HTTP. Both properties disappear when traffic consolidates onto one multiplexed connection, and both have to be re-engineered when it does.

The second is legibility. An HTTP/1.1 exchange is readable in a packet capture with no decoder and no keys, which during an incident is worth more than it sounds. The same capture of an HTTP/2 connection is opaque without tooling, and the tooling needs the TLS keys to be useful at all.

There is also a smaller point about bulk transfer: for a single large response there is nothing to multiplex and nothing to compress in the headers, so HTTP/1.1 with a warm connection performs as well as anything and brings none of the flow control tuning that HTTP/2 requires to match it.

The conclusion is not that HTTP/1.1 is better, it is that the limit HTTP/2 removes is a concurrency limit. Where concurrency is not the constraint, the newer protocol is buying little and still charging for the operational differences.

Common Pitfall

A client that creates its HTTP client object inside the request handler gets a fresh connection pool per request and therefore a TCP and TLS handshake per request. This is the most common and most expensive HTTP performance bug in backend code, it is invisible in code review because the line looks harmless, and it is identified immediately by comparing the handshake rate to the request rate. The client object belongs at module or application scope, constructed once.