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.
The problem
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).
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
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.Transportkeeps 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.
1read bytes until "\r\n\r\n" // scan; header block may span several TCP reads2split first line on spaces → method, target, version3for each header line: split on ":" , trim OWS, lowercase name4if "transfer-encoding" contains "chunked": // takes precedence over content-length5 loop: read hex length line, read that many bytes, read "\r\n" until length 06elif "content-length" present: // reject if duplicated with different values7 read exactly N bytes8else: no body (request) / read until close (response, 1.0 style)Key points
- HTTP/1.1 keeps the connection open by default;
Connection: closeends it. Bodies end atContent-Lengthbytes 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-Lengthfrom an estimate; the client stops reading at N bytes or hangs waiting for more. - A response with
Content-Lengthshorter 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-EncodingvsContent-Lengthprecedence; 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.