Fiber-Based Concurrency in Ruby, Explained

What Fiber::Scheduler actually is, how it differs from a Thread pool, and why it's a particularly good fit for an HTTP client.

What a Fiber actually is

A Ruby Fiber is a unit of execution you control by hand: it runs until it explicitly yields, and nothing switches away from it involuntarily the way a Thread can be preempted mid-line. On its own that's just cooperative scheduling — mildly interesting, not obviously useful for I/O. What makes it useful is Fiber::Scheduler (Ruby 3.0+): a hook that lets a library intercept blocking operations — a socket read, sleep, a Mutex wait — and, instead of blocking the whole OS thread, yield control to another Fiber that has work to do, resuming the first one automatically once its I/O is actually ready.

The async gem (the reactor Butler is built on) implements exactly that scheduler. Nothing about writing Fiber-scheduled code looks different from writing ordinary synchronous Ruby — no callbacks, no .then chains, no colored async/await functions — because the yielding happens underneath a normal-looking blocking call, not in application code.

Thread-per-request vs. Fiber-per-request

A thread-per-request model (the classic way to add concurrency to a blocking I/O client) gives every in-flight request its own OS thread. That works, but OS threads aren't free: each one carries real memory (a stack, kernel scheduling structures) and real context-switch cost, and Ruby's GVL means only one thread runs Ruby bytecode at a time regardless — threads help exactly because I/O waits release the GVL, not because Ruby got parallel.

Fibers pay almost none of that overhead. Thousands of them can be alive at once on a single OS thread, because a Fiber that's waiting on I/O isn't consuming a kernel thread's worth of resources while it waits — it's just a suspended, resumable stack the scheduler is holding onto. For a workload that's mostly "waiting on the network" (which describes almost every HTTP client call), that's the exact shape of overhead you don't need to pay.

Why this fits HTTP clients specifically

An HTTP request spends the overwhelming majority of its wall-clock time waiting: DNS, TCP connect, TLS handshake, waiting for the server's response. A client library's job during almost all of that time is to do nothing but hold state and wait to be resumed — which is precisely what a Fiber does well and a Thread does at a real, measurable cost. Fanning out 50 concurrent requests as 50 Fibers instead of 50 Threads means avoiding 50 threads' worth of memory and scheduling overhead for work that's fundamentally "wait, then do a small amount of CPU work, then wait again."

How Butler uses this

client.async { |tasks| tasks.async { client.get("/a") } } gives you real structured concurrency — an Async::Barrier underneath resolves any child task the block forgot to .wait and cancels anything still running after an exception, so exiting the block always leaves every child task resolved or cancelled. That's the shape you want for "fetch these 5 endpoints concurrently" without hand-rolling your own thread-pool bookkeeping.

The less obvious part is what happens for an ordinary, non-concurrent client.get call. The naive way to make one async-native call "just work" synchronously is Async { ... }.wait — spin up a whole reactor, run the block, tear it down — and that's real, measurable overhead paid on every single bare call. Butler avoids it with one background reactor (Butler::Async.background), running in one dedicated Thread, lazily started once and shared by every Butler::Client in the process for its whole lifetime. Client#request checks whether it's already inside a reactor first (a nested client.async, or a Fiber-scheduler-based app server like Falcon) and runs inline with zero dispatch overhead if so; otherwise the work is handed to that shared background reactor over a plain Thread::Queue, and the calling thread blocks on a second Queue until the result comes back — invisibly, so a bare client.get("/users") still looks and behaves like an ordinary synchronous method call from the outside.

Further reading