Ruby Fiber Scheduler for Async IO in Practice
The Ruby fiber scheduler helps IO-bound code stay responsive without callback-heavy control flow. Ruby 3.1 introduced the Fiber Scheduler as a built-in mechanism for writing asynchronous code without the complexity of callbacks or full async frameworks. If you’ve ever wanted to handle thousands of concurrent connections or make your IO-bound code more efficient, the Fiber Scheduler is worth knowing.
Key Takeaways
- The fiber scheduler lets Ruby pause IO-bound work and run other fibers instead
- It is best for network calls, sleeps, and other operations that would otherwise block the thread
- It does not make CPU-heavy work parallel, so it is not a replacement for threads or Ractors
- The cleanest examples usually start with
Fiber.scheduleand a small, focused scheduler block - A small scheduler example is enough to show the pattern before you scale it up
If you only need one mental model, keep this one: the scheduler helps Ruby give time to other fibers while one fiber waits on IO. That makes it a good fit for request fan-out, feed readers, background polling, and other places where waiting is the expensive part.
How the Ruby fiber scheduler works
Before diving in, understand the building blocks. Fibers are lightweight concurrency primitives that you explicitly pause and resume. Unlike threads, fibers are cooperative—they yield control only when you tell them to, avoiding the synchronization headaches that come with threads.
The Fiber Scheduler extends this idea to IO operations. When a fiber performs a blocking IO operation (reading from a socket, waiting for a file, sleeping), the scheduler can suspend that fiber and run other fibers instead. This lets you handle many concurrent operations in a single thread.
Think of it as a single-threaded event loop, similar to what Node.js does, but built into Ruby and using fibers instead of callbacks.
That distinction matters because it keeps the syntax familiar to Ruby developers. You still write Ruby blocks, method calls, and normal control flow. The scheduler simply decides when a waiting fiber should yield so another fiber can continue.
Enabling the scheduler
Ruby 3.1+ includes a built-in scheduler via Fiber.schedule and the Fiber::Scheduler class. Enable it by running code within the scheduler’s block:
require 'fibers'
Fiber::Scheduler.new do
# All async operations here use the scheduler
end.run
At this level, the main thing to notice is that the scheduler wraps the work you want to coordinate. Once that block starts, any supported blocking call can hand control back to the scheduler instead of freezing the whole thread.
Basic example: non-blocking sleep
The simplest example shows how the scheduler improves efficiency. Regular sleep blocks the entire thread, but with the scheduler, other fibers can run during the wait:
Fiber::Scheduler.new do
5.times do |i|
Fiber.schedule do
puts "Fiber #{i} starting"
sleep 1
puts "Fiber #{i} finished"
end
end
end.run
puts "All fibers done!"
Without the scheduler, this would take 5 seconds. With it, all five fibers run concurrently, taking only about 1 second.
That tiny example is useful because it isolates the behavior. The work inside each fiber is not doing anything clever, so the only difference is whether the scheduler can move on while one fiber waits. That makes it easy to see why this feature matters for IO-heavy code. When you replace sleep with a real network request, the same principle applies: the scheduler keeps the thread useful while each fiber waits on a response.
Non-blocking IO operations
The scheduler works with any IO operation that supports non-blocking mode. Here’s a simple HTTP fetcher that fetches multiple URLs concurrently:
require 'net/http'
require 'fibers'
urls = [
'https://httpbin.org/delay/1',
'https://httpbin.org/delay/1',
'https://httpbin.org/delay/1'
]
Fiber::Scheduler.new do
urls.each do |url|
Fiber.schedule do
uri = URI(url)
response = Net::HTTP.get_response(uri)
puts "Got response: #{response.code}"
end
end
end.run
puts "All requests complete"
Each HTTP request runs concurrently. The total time is roughly the time of the slowest request, not the sum of all requests.
In real code, the details are a little less tidy because network failures, timeouts, and retries all matter. Still, the shape of the solution is the same: put the slow IO work behind fibers, let the scheduler coordinate the wait, and keep the rest of your application logic straightforward.
Custom scheduler implementation
You can create your own scheduler to add logging, metrics, or custom behavior:
class CustomScheduler < Fiber::Scheduler
def initialize
super
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
def before_fiber(fiber)
puts "Starting fiber #{fiber.object_id}"
end
def after_fiber(fiber)
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
puts "Fiber #{fiber.object_id} done in #{elapsed.round(2)}s"
end
def close
puts "Scheduler closed"
super
end
end
CustomScheduler.new.run do
Fiber.schedule { sleep 0.5; puts "Done!" }
end
A custom scheduler is usually only worth the effort when you need to observe or shape the scheduling process. For most applications, the built-in scheduler is enough. Custom behavior becomes useful when you want metrics, tracing, throttling, or a stricter policy around how concurrent tasks are admitted.
Handling errors
The scheduler provides hooks for handling errors in async code:
Fiber::Scheduler.new do
Fiber.schedule do
raise "Something went wrong!"
end
end.run
# Raises error in the scheduler's context
For more control, override the process_error method. The default behavior simply raises the error, which stops the scheduler immediately. In a production system, you often want to log the failure, collect it for later reporting, or let other fibers continue even when one encounters a problem. Subclassing the scheduler lets you intercept errors before they propagate.
class ErrorTrackingScheduler < Fiber::Scheduler
def initialize
@errors = []
super
end
def process_error(fiber, error)
@errors << { fiber: fiber, error: error }
puts "Caught error: #{error.message}"
end
def errors
@errors
end
end
When you handle errors in this layer, keep the reporting path simple. The scheduler should surface what failed, but it should not swallow the fact that the task failed in the first place. Good async error handling makes the failure visible and keeps the rest of the pipeline predictable.
Use cases
Concurrent web requests
Process multiple API calls simultaneously without threads:
def fetch_all(urls)
results = []
Fiber::Scheduler.new.run do
urls.each do |url|
Fiber.schedule do
results << fetch_one(url)
end
end
end
results
end
This pattern is common in services that aggregate data from several APIs. The scheduler helps most when each request is individually simple but the combined wait time would otherwise stack up. In other words, it is a coordination tool more than a speed boost.
Real-time feeds
Handle multiple data streams concurrently:
Fiber::Scheduler.new.run do
Fiber.schedule { listen_to_feed(:orders) }
Fiber.schedule { listen_to_feed(:inventory) }
Fiber.schedule { listen_to_feed(:notifications) }
end
This is a good fit for dashboards, live monitoring tools, and systems that need to react to several slow-but-important sources at once. The scheduler keeps the listeners moving without forcing you to build a separate thread for each stream, which makes the code easier to reason about when the number of inputs grows.
Connection pooling
Manage database or connection limits while maximizing throughput:
class ConnectionPoolScheduler < Fiber::Scheduler
def initialize(max_connections)
@semaphore = Concurrent::Semaphore.new(max_connections)
super()
end
def block(blocker, timeout)
# Wait for a connection slot
@semaphore.acquire
super
ensure
@semaphore.release
end
end
Even here, the scheduler is not replacing the pool itself. It simply gives your fibers a structured way to wait for resources without blocking the entire thread while the pool is full. That matters when your application has to deal with short bursts of demand and you want the waiting time to stay cheap.
Limitations
The Fiber Scheduler isn’t a silver bullet. Keep these in mind:
- Only non-blocking IO: Operations that truly block (CPU-intensive work, certain C extensions) won’t yield to other fibers
- Single-threaded: You won’t get true parallelism for CPU-bound tasks—use threads or Ractor for that
- Library support: The scheduler only helps if the IO operations support it. Ruby’s stdlib does, but some gems may still block
The practical takeaway is that the scheduler is excellent for waiting, but not for doing more work at once. If your bottleneck is CPU time, memory churn, or a blocking extension, the scheduler will not solve the problem on its own.
Common questions
When should I choose the fiber scheduler over threads?
Choose the scheduler when you have lots of IO waits and want simple, readable coordination in a single thread. Choose threads when you need true parallel execution or when a library does not cooperate with fibers.
Does the fiber scheduler change my business logic?
Usually no. Your business logic stays mostly the same, but the places where you wait on IO become non-blocking and scheduler-aware. That keeps the concurrency concerns close to the edges of the application.
What is the biggest mistake people make?
The most common mistake is expecting it to speed up CPU-bound code. The scheduler helps you spend less time blocked, but it does not make expensive computation cheaper.
Summary
Fiber::Schedulerenables efficient concurrent IO in a single thread- Use
Fiber.scheduleto run blocks concurrently - The scheduler automatically yields during IO operations
- Create custom schedulers for logging, metrics, or specialized behavior
- Best for IO-bound workloads with many concurrent operations
This approach sits between threads, which can give you more parallelism but also more complexity, and async/await systems in other languages, which often hide similar scheduling rules behind different syntax. If you need concurrency without the thread overhead, the fiber scheduler is a strong fit, especially when the code spends most of its time waiting rather than computing.
See Also
- Concurrency with Threads — Thread-based concurrency in Ruby and when to use threads vs fibers
- Blocks, Procs, and Lambdas — Understanding Ruby’s callable objects, which form the foundation for fibers
- Ractor Concurrency — Ruby’s actor-based parallelism model for CPU-bound workloads that fibers cannot speed up