Ruby's HTTP client landscape is a set of trade-offs, not a clear winner — here's what each one is actually strong at, and where it isn't.
The standard library client — always available, no dependency to add. Low-level by design: no HTTP/2, no retries, no timeouts unless you set them explicitly (and it's easy to forget one and ship a client with no read timeout at all). A reasonable choice when you're making one or two simple calls and don't want a dependency; a poor foundation to build a service's entire outbound HTTP layer on without wrapping it yourself first.
The ecosystem play — a huge middleware library and pluggable adapters (Net::HTTP, Excon, Typhoeus, and others underneath the same API). That flexibility is also its cost: the adapter/middleware abstraction is a real layer of indirection, and Faraday's own behavior can shift depending on which adapter you've plugged in underneath it. Strong choice if you want that ecosystem's breadth and are fine configuring resilience yourself via middleware.
Performance-focused, with a long track record in production. Less opinionated about resilience/developer-experience out of the box than newer clients — you're more likely to be reaching for extra gems or writing your own retry/circuit-breaker logic around it.
A genuinely Ruby-friendly API (chainable, readable call sites). Its concurrency/transport model is different from a Fiber-scheduler-based one — worth checking directly against your own app's concurrency model (thread-per-request web server vs. a Fiber-scheduler-based one like Falcon) before assuming it composes the way you'd expect.
The simplest possible ClassName.get(url) call, and genuinely the fastest way to get
something working. The cost of that simplicity: no connection reuse across calls (each bare
.get opens fresh), no HTTP/2, and no built-in resilience — fine for a quick script or
prototype, a real gap for anything calling the same API repeatedly in production.
An excellent async foundation — real Fiber-native HTTP/1.1 and HTTP/2, per-origin connection
pooling, stream multiplexing, all implemented well. What it doesn't give you is a client-facing
API or resilience layer above that foundation — you're building your own
Client/Request/Response abstraction, your own retry policy,
your own circuit breaker, on top of it yourself.
Butler's position: take the strongest idea from each without the downsides it usually comes
with — a small, deliberately-scoped public API (Client, Request,
Response, Headers) in front of real HTTP/1.1 and HTTP/2,
Fiber-native concurrency (built on the same reactor Async::HTTP provides, not a
competing implementation of it), and resilience/security/observability that don't require four
extra gems to assemble yourself. The trade-off is real too: Butler is younger and has a smaller
ecosystem than Faraday's — if you need Faraday's specific middleware library, or an adapter it
supports that Butler doesn't aim to, that's a legitimate reason to stay with it.
| Capability | Butler | Faraday | Excon | Net::HTTP |
|---|---|---|---|---|
| Fiber-native concurrency | ✓ | — | — | limited |
| HTTP/2 (ALPN, multiplexed) | ✓ (first-class) | adapter-dependent | limited | depends |
| Connection pooling | ✓ | adapter | ✓ | manual |
| Retries + backoff/jitter | built-in | middleware | limited | manual |
| Circuit breaker | built-in | external gem | external gem | external gem |
| Deadlines (total budget across retries) | ✓ | — | — | — |
| Native request stubbing | ✓ | via WebMock | via WebMock | via WebMock |
This describes each library's design intent, not an independently re-verified benchmark of every listed library's current release — re-check before quoting it externally. Full matrix (including HTTParty, HTTP.rb, and Async::HTTP) and a real Sequential/Concurrent benchmark run against several of these clients in the README.