Skip to content

HTTP/1.1

Open a page with forty small images on it and watch the network panel of any browser: they don't all start downloading at once. A batch of six starts, then as each finishes, another takes its place, in visible waves. That pattern isn't a bandwidth limit — it's HTTP/1.1 working exactly as designed, and the design has a specific, well-understood reason for looking like this.

Persistent connections: the fix for HTTP/1.0's biggest cost

HTTP/1.0, HTTP/1.1's predecessor, opened a brand new TCP connection for every single request by default and closed it once the response finished. Fetching a page with ten embedded images meant ten separate TCP three-way handshakes, each paying at least one full round trip before a single byte of the actual resource moved — pure overhead, repeated ten times, for resources going to the exact same server.

HTTP/1.1, standardized in 1997, made persistent connections the default: unless a Connection: close header says otherwise, the TCP connection stays open after a response finishes, ready to carry the next request to the same server without repeating the handshake. curl -v shows this directly when fetching two resources from the same host in one invocation:

curl -v http://example.com/style.css http://example.com/logo.png
* Connected to example.com (93.184.215.14) port 80
> GET /style.css HTTP/1.1
> Host: example.com
> Connection: keep-alive
>
< HTTP/1.1 200 OK
< Content-Length: 1840
<
* Connection #0 to host example.com left intact
* Re-using existing connection with host example.com
> GET /logo.png HTTP/1.1
> Host: example.com
> Connection: keep-alive
>
< HTTP/1.1 200 OK
< Content-Length: 5210

The line Re-using existing connection is the entire point: the second request skips the handshake entirely, riding the same already-open TCP connection the first request used. This is why Connection: keep-alive shows up so often in captured HTTP/1.1 traffic even though it's the default behavior — many clients set it explicitly anyway, defensively, in case they're talking to an older server that still assumes HTTP/1.0's close-after-each-request behavior.

Pipelining exists in the spec, and almost nobody uses it

Persistent connections solve the handshake-repetition cost, but they still leave requests going out one at a time: send a request, wait for the full response, then send the next one. HTTP/1.1's specification allows something more aggressive — pipelining, sending a second request before the first response has arrived, so several requests can be in flight on the wire at once.

In practice, no major browser enables pipelining by default, and it never saw meaningful real-world adoption. Two problems killed it. First, a real, widely deployed population of proxies and intermediate servers handled pipelined requests incorrectly — reordering responses, or breaking outright — badly enough that enabling it caused visible bugs rather than the intended speedup. Second, and more fundamentally, pipelining doesn't actually solve the blocking problem it looks like it should: responses on a pipelined connection still have to come back in the same order the requests were sent, so one slow response at the front of the queue still blocks every response behind it from being delivered, even though the requests went out concurrently. This particular failure mode — one slow or stuck item blocking everything queued behind it on the same channel — is called head-of-line blocking, and it's the thread running through this entire module: HTTP/1.1 has it at the application layer, and as the next article covers, HTTP/2 fixes this specific instance of it but leaves a version of the same problem sitting one layer down, in TCP itself.

The real workaround: six connections per host

Since one connection can't usefully carry multiple requests at once in practice, browsers settled on a blunter fix: open several persistent connections to the same host in parallel — six became the de facto common limit across major browsers — and spread requests across them. This is exactly what produces the wave pattern described at the top of this article: with forty images and six connections, the browser starts six downloads, and each connection picks up a new request from the queue as soon as its current one finishes.

ss shows this directly on the client side during a page load with many resources on one host:

ss -tn state established '( dport = :443 )'
State      Recv-Q Send-Q  Local Address:Port    Peer Address:Port
ESTAB      0      0       10.0.2.15:51422       93.184.215.14:443
ESTAB      0      0       10.0.2.15:51424       93.184.215.14:443
ESTAB      0      0       10.0.2.15:51426       93.184.215.14:443
ESTAB      0      0       10.0.2.15:51428       93.184.215.14:443
ESTAB      0      0       10.0.2.15:51430       93.184.215.14:443
ESTAB      0      0       10.0.2.15:51432       93.184.215.14:443

Six established connections to the same remote address on port 443, each from a different local ephemeral port — this is the six-connections-per-host limit made visible, not a coincidence or a misconfiguration.

For a while, sites worked around the six-connection ceiling with domain sharding: serving static assets from several subdomains (img1.example.com, img2.example.com, and so on) specifically so the browser's per-host limit would apply separately to each one, multiplying the effective parallelism. It's a workaround this course won't recommend building today — HTTP/2's multiplexing, covered next, removes the need for it outright, and sharding actually works against HTTP/2, since it splits traffic across connections instead of consolidating it onto the single multiplexed one HTTP/2 wants.

Practical scenario: a page that "hangs" on one slow request

A monitoring dashboard loads a mix of small JSON widgets and one endpoint that occasionally takes eight or nine seconds to respond — a report-generation call that's slow by design, not by accident. Users report that when this happens, unrelated widgets on the same page also freeze for the same eight or nine seconds, even though they don't depend on the slow endpoint's data at all.

A capture during a slow load, filtered to the dashboard's own domain, shows all of the page's requests distributed across six established connections, exactly as the pattern above predicts — and the slow report request occupies one of those six connections for its full duration, leaving only five connections free for everything else. If the page happens to have more than five other pending requests queued at that moment, at least one of them has to wait for one of the six connections to free up, and if the slow request is the one holding that connection, the wait is however long the slow request takes. This isn't pipelining's head-of-line blocking specifically — it's the coarser version of the same underlying limit: a fixed number of connections is still a fixed number of connections, and one of them being busy for nine seconds is nine seconds of reduced parallelism for everything else sharing the host.

The fix that actually addresses this at the root, rather than tuning connection counts further, is serving the slow endpoint from a separate origin so it doesn't compete for the same six-connection budget — or, more durably, moving to HTTP/2, which removes the fixed-connection-count model this scenario depends on entirely.

Practice exercises

  1. Run curl -v against a site of your choice, fetching two different resources from the same host in one invocation as in this article's example, and confirm in the output whether the second request reused the first connection.
  2. Using ss -tn, load a page with many resources from a single host in a browser and count how many simultaneous established connections appear to that host's IP address. Does the number match what this article describes?
  3. Explain why domain sharding, once a common performance technique, is now actively discouraged for a site being served over HTTP/2.

The six-connection workaround treats the symptom — one connection can't usefully carry multiple concurrent requests — without touching the cause. HTTP/2 attacks the cause directly, replacing "one request per connection at a time" with genuine multiplexing on a single connection, and it's worth understanding exactly how that trick works before assuming it solves head-of-line blocking completely.

Sources