HTTPhttp/1.1keep-alivepersistent connectionspipeliningchunked

HTTP/1.1: Persistent Connections and Their Limits

HTTP/1.1 keeps the TCP connection open between requests and delimits bodies with Content-Length or chunks, but it can only carry one response at a time per connection — so browsers open several connections per origin, and text parsing costs bytes and ambiguity on every message.

ConceptualBrowser
Interview question
Progress

The problem

HTTP/1.0 opened a TCP connection per request and closed it after the response: one page with 50 assets meant 50 handshakes and 50 slow starts. Keeping the connection open fixes that — but a connection that carries requests strictly one after another has a new problem when the page wants 50 things at once.

Persistent connections by default

HTTP/1.0 closed the connection after each response; the end of the body was "the peer closed the socket". HTTP/1.0 clients could ask for more with Connection: keep-alive, but it was an extension. HTTP/1.1 (1997, now RFC 9112) made persistent connections the default: after a response the connection stays open, the next request is written on the same socket, and either side signals the end with Connection: close. Each reuse saves a The Three-Way Handshake, a The TLS Handshake and the slow-start ramp — see Keep-Alive and Connection Reuse for the numbers and for the idle-timeout race that comes with it.

Persistence needs the receiver to know where a body ends without the socket closing. Content-Length gives the exact byte count and is preferred. When the length is not known up front — a streamed response, a template rendered on the fly, a proxy relaying as it receives — `Transfer-Encoding: chunked` sends the body as pieces, each prefixed with its hexadecimal length, terminated by a zero-length chunk and optional trailers. Chunked is what makes streaming responses possible over 1.1, and it is also what makes Server-Sent Events work (Polling vs Long Polling vs SSE vs WebSockets).

A chunked response: each piece is length-prefixed, `0` ends the body
HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked

7\r\n
Mozilla\r\n
9\r\n
Developer\r\n
7\r\n
Network\r\n
0\r\n
\r\n

One at a time: the parallelism problem

Browser

A persistent HTTP/1.1 connection is strictly sequential: the client sends a request, waits for the complete response, then sends the next. Responses carry no identifier tying them to a request; they are matched by order alone. So one connection gives one request in flight, and a 300 ms API call blocks every request queued behind it on that connection — an application-level head-of-line block, distinct from TCP’s (Head-of-Line Blocking).

Pipelining was HTTP/1.1’s answer: send several requests without waiting, and the server answers them in order. It is in the specification and effectively unused. Servers had to buffer responses to preserve order (a slow first response still blocks the rest), many proxies and middleboxes mishandled it, and a single corrupted or reordered response desynchronised the whole connection. Firefox shipped it off by default, Chrome tried it and removed it in 2014, and no current browser uses it. The other answer, and the one that stuck, was more connections: mainstream browsers open up to about six parallel connections per origin (hostname plus port), and queue requests beyond that.

Six connections per origin shaped a decade of web performance practice: domain sharding (img1.example.com, img2.…) to get 12 or 18 connections, spriting images and concatenating scripts to need fewer requests, inlining small assets. Every one of these is a workaround for sequential connections, and every one became an anti-pattern once HTTP/2: Streams on One Connection multiplexed a single connection. Behaviour is per implementation: the "six" is a convention, not a standard, and non-browser clients (a Node agent, a Go transport, a Python session) have their own, often much smaller, defaults.

  • One request in flight per connection; responses are matched by order, so there is no way to interleave.
  • Pipelining: specified, rarely deployed, disabled in all current browsers — do not design around it.
  • Browsers: ~6 connections per origin (implementation-specific, and often fewer on mobile or under memory pressure).
  • Non-browser clients differ: Go’s http.Transport keeps 2 idle connections per host by default, urllib3 pools 10, Node’s agent is unbounded unless configured.

Why text parsing is a cost

HTTP/1.1 is a text protocol: header names are case-insensitive strings, values are free-form, whitespace rules have edge cases, and the parser has to scan for \r\n byte by byte to find the end of each line and the blank line that ends the headers. Headers are sent uncompressed and in full on every request — a browser request with cookies and a User-Agent string is typically 500–900 bytes, and 30 requests to the same origin repeat almost all of it. For an API server handling tens of thousands of small requests per second, header parsing is a measurable share of CPU, which is why picohttpparser, llhttp (Node) and hyper’s httparse are hand-optimised.

The deeper cost is ambiguity. Two parsers can legitimately disagree about where a message ends — Content-Length and Transfer-Encoding both present, a duplicated Content-Length, an obfuscated Transfer-Encoding : chunked with a space — and when a front proxy and a back server disagree, an attacker can prefix the next user’s request with their own: request smuggling. Binary framing with explicit lengths in HTTP/2: Streams on One Connection removes the ambiguity class entirely, which is one of its less advertised benefits.

What a 1.1 parser has to do for every message
1read bytes until "\r\n\r\n" // scan; header block may span several TCP reads
2split first line on spacesmethod, target, version
3for each header line: split on ":" , trim OWS, lowercase name
4if "transfer-encoding" contains "chunked": // takes precedence over content-length
5 loop: read hex length line, read that many bytes, read "\r\n" until length 0
6elif "content-length" present: // reject if duplicated with different values
7 read exactly N bytes
8else: no body (request) / read until close (response, 1.0 style)

Key points

  • HTTP/1.1 keeps the connection open by default; Connection: close ends it. Bodies end at Content-Length bytes or the zero chunk of chunked encoding.
  • Chunked transfer encoding is how a server streams a body of unknown length over a persistent connection.
  • One request in flight per connection, responses matched by order — application-level head-of-line blocking.
  • Pipelining is specified but effectively unused; browsers instead open ~6 connections per origin (implementation-specific).
  • Domain sharding, sprites and concatenation were workarounds for the connection limit and became anti-patterns under HTTP/2.
  • Text headers cost bytes (repeated on every request) and CPU, and their parsing ambiguity is the root of request smuggling.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why did HTTP/1.1 make persistence the default instead of keeping HTTP/1.0 semantics?

Because every page had grown to dozens of assets and each connection cost a handshake and a slow start; measured page loads improved dramatically with reuse, and making it opt-out meant every client benefited without changes.

Why can the client not just send requests concurrently on one connection?

Responses have no identifier; the only way to match them to requests is order. Concurrency needs a stream id on every frame, which is what HTTP/2 added.

Why did pipelining fail when it was in the spec?

It kept in-order responses, so a slow one still blocked the rest; intermediaries frequently broke it; and a single framing error desynchronised the connection. The upside was too small and the failure mode too ugly, so browsers chose parallel connections instead.

Why does chunked encoding exist when Content-Length is simpler?

A persistent connection needs the body’s end to be explicit, but a server streaming output does not know the length until it finishes. Chunking makes each piece self-delimiting so streaming and persistence coexist.

How it fails

What the failure looks like from inside real software.

  • Requests queue in the browser behind six slow connections to one origin; DevTools shows long "Stalled"/"Queueing" bars while the server is idle.
  • A streaming endpoint sets Content-Length from an estimate; the client stops reading at N bytes or hangs waiting for more.
  • A response with Content-Length shorter than the body leaves extra bytes on the socket, and the next response parses as garbage (HPE_INVALID_CONSTANT, "Parse Error").
  • Front proxy and backend disagree on Transfer-Encoding vs Content-Length precedence; request smuggling lets one user’s request poison another’s.
  • Sequential API calls on one keep-alive connection from a client library that does not pool: a slow call delays unrelated ones.
  • A proxy that does not support chunked responses buffers the whole body before forwarding; streaming appears to hang until the end.