Making the Rails Default Job Queue Fiber-Based

earcar1 pts0 comments

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 =>...

queue fiber thread async rails solid

Related Articles