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.
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.
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 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.
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.
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.