HTTP/2 Multiplexing vs. HTTP/1.1 Connection Pooling

Two different answers to the same problem — "how do I send many requests to one host without waiting for each one to finish first" — and why they perform differently under real concurrency.

The problem both are solving

A single TCP+TLS connection can only carry one HTTP/1.1 request/response at a time — send a request, wait for the full response, only then can the next request go out on that same connection. Fire off five concurrent requests to the same host over HTTP/1.1 and something has to give: either they queue up behind each other on one connection, or the client opens more connections and spreads the requests across them.

HTTP/1.1's answer: connection pooling

Open several TCP+TLS connections to the same host (traditionally capped somewhere around 6-8 per host — browsers and most HTTP libraries converge on a similar number) and hand out one per in-flight request, reusing an idle one once its request completes rather than paying DNS/TCP/TLS setup again. This works, and it's simple, but it has a hard ceiling: with a fixed pool size, request six has to wait for one of the first five to free up a connection, no matter how idle the server actually is.

HTTP/2's answer: multiplex over one connection

HTTP/2 introduces streams: many logical request/response exchanges interleaved over a single TCP+TLS connection, each identified by a stream ID, with frames from different streams free to interleave on the wire. The practical effect is that "how many requests can be in flight to this host right now" stops being bounded by a small connection-pool number and becomes bounded by the server's own concurrent-stream limit instead — usually far higher, and one TLS handshake instead of several.

What this actually buys you

Fewer TCP+TLS handshakes (cheaper for both sides, especially over a higher-latency network, since each handshake costs its own round trips before any request bytes even go out), and no artificial "wait for a free connection" ceiling on how much concurrency one host can serve. It's not free either: head-of-line blocking moves from the connection level to the TCP level (a lost packet can still stall every multiplexed stream sharing that one TCP connection), and a server that doesn't actually raise its concurrent-stream limit meaningfully won't give you much over a well-tuned HTTP/1.1 pool.

How Butler negotiates and pools each one

Butler's default (http_version: :auto) offers both h2 and http/1.1 during the TLS handshake's ALPN extension and lets the server pick — HTTP/2 whenever it supports it, HTTP/1.1 otherwise, no separate negotiation step. :http1/ :http2 force one or the other, settable both on a client and per call (client.get(path, http_version: :http1)).

Because the two protocols behave differently at the connection level, Butler's connection pool fingerprints on http_version alongside origin — a call pinned to :http1 never reuses (or evicts) a connection that already negotiated :http2 for the same host, and vice versa. The pool itself doesn't reimplement per-origin pooling or HTTP/2 stream multiplexing by hand — Async::HTTP::Client already does both internally, so Butler's ConnectionPool is a thin, bounded (LRU-evicted) registry mapping that fingerprint to one memoized client.

Further reading