rubyguides

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.schedule and 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::Scheduler enables efficient concurrent IO in a single thread
  • Use Fiber.schedule to 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