Making the Rails Default Job Queue Fiber-Based
Search for Blog
Ruby
Async
Rails
Solid Queue
Performance
Concurrency
Open Source
Making the Rails Default Job Queue Fiber-Based
Carmine Paolino
Apr 21, 2026
Update, July 31, 2026: Solid Queue 1.6.0 has shipped with fiber worker execution. The default Rails job queue can now run many long-running, cooperative I/O-bound jobs like LLM streaming far more efficiently, without making the queue database pool grow with the number of jobs waiting on I/O. The setup below now uses the official release.
Last year I moved the LLM streaming jobs in Chat with Work to Async::Job. It was fast. Genuinely fast. Fiber-based execution with Redis, thousands of concurrent jobs on a single thread. I was so convinced that I wrote a whole post about why async Ruby is the future for AI apps and recommended it to everyone.
Then I started hitting walls.
Async::Job doesn’t persist jobs. They go into Redis and they’re gone. Mission Control shows nothing. Background jobs in Rails are already quieter than the rest of your application – they fail without anyone noticing unless you go looking. Even with Honeybadger catching exceptions, I still want to see the full picture: which jobs are queued, which are running, which failed, what the system looks like right now. Without job persistence, you don’t get that.
Solid Queue is the default in Rails 8. Every new Rails app ships with it. When someone picks up Rails to build an LLM application and their 25-thread worker pool can only handle 25 concurrent streaming conversations, the answer shouldn’t be “swap your entire job backend.” It should be “change one line of config.”
So I opened a PR. On July 31, it shipped in Solid Queue 1.6.0.
Threads vs fibers, quickly
If you already know this, skip ahead to the config.
By default, Solid Queue runs each job on its own thread. Those threads can all query the database concurrently, so conservative pool sizing assumes one connection per execution thread. There is also stack memory and kernel thread overhead. For a job that crunches data for 30 seconds, that’s fine – the thread is busy. For a job that streams an LLM response for 30 seconds but spends 99% of that time waiting for tokens, the thread is just sitting there holding resources.
Fibers sidestep much of this. They are cooperatively scheduled in userspace on a single thread. When a fiber hits scheduler-aware I/O – an Async::HTTP request or waiting for the next token through a compatible client – it steps aside and another fiber picks up. One thread, hundreds of concurrent jobs. No kernel thread overhead per job, and database pools can be sized for simultaneous database work rather than every job waiting on network I/O.
The async gem installs the fiber scheduler. Ruby operations such as Kernel.sleep, scheduler-aware IO, and fiber-aware libraries yield without changing the job itself. This is not magic around every blocking call: a library or C extension that does not cooperate with the scheduler can still block the reactor thread.
For the full deep dive – processes, threads, fibers, the GVL, I/O multiplexing – see Async Ruby is the Future.
The switch
Fiber mode ships in Solid Queue 1.6.0. Upgrade Solid Queue and add async as an application dependency:
# Gemfile<br>gem "solid_queue", "~> 1.6"<br>gem "async" # required for fiber workers
Then switch that worker’s execution setting:
# config/queue.yml<br>production:<br>workers:<br>- queues: ["*"]<br># threads: 10<br>fibers: 100 #<br>processes: 2
Your jobs don’t change. Your queue doesn’t change. The worker runs them as fibers instead of threads.
threads or fibers. Pick one per worker.
Fiber-scoped Rails isolation is required. Add this to your Rails application configuration:
# config/application.rb<br>config.active_support.isolation_level = :fiber # required for fibers
Fibers share a thread, so they need fiber-scoped state instead of the default thread-scoped state. Solid Queue validates this at boot and refuses to start fiber workers if the application still uses thread-scoped isolation.
That isolation setting is global to the Rails application, not local to Solid Queue. Also, fiber worker execution is separate from Solid Queue’s supervisor async mode. The configuration above uses the default fork supervisor, so processes: 2 creates two worker processes and each gets its own fiber reactor. If you start bin/jobs --mode async, the workers share the supervisor process and the processes setting is ignored.
Under the hood
The core of the implementation is FiberPool. It starts its reactor lazily when the first execution is posted, so the pool can be constructed safely before the default supervisor forks. A single thread runs one async reactor, with a semaphore capping concurrency at whatever number you set:
def start_reactor<br>create_thread do<br>Async do |task|<br>semaphore = Async::Semaphore.new(size, parent: task)<br>boot_queue :ready
wait_for_executions(semaphore)<br>end<br>rescue Exception =>...