Bringing Rails into the Ractor-Age

ksec1 pts0 comments

Bringing Rails into the Ractor-age | Rails at Scale

Ractors have been experimental for years, but things are changing fast. Last year, a team of four on the Shopify Ruby Infrastructure team tackled Ractor scalability, performance, and stability. Ractors are now viable in production, and Ruby 4.1 will make them even better.

It’s time for Rails to embrace them.

In this post, we’ll explain why: what Ractors unlock for Rails applications, and the first milestone we’re aiming for in the framework.

First, a refresher

A Ractor, or Ruby Actor, brings true multi-core parallelism to Ruby. If you aren’t familiar with CRuby internals, you might expect Threads to already provide this — but they don’t, because of the Global VM Lock (GVL). Threads give you concurrency; Ractors give you parallelism.

For a Rails application, that unlocks two things:

Speed — CPU-bound work can actually run in parallel instead of taking turns.

Memory — you get that parallelism inside a single process, instead of forking a full copy of the app per worker.

The cost of forking

When several users hit a threaded Rails server like Puma, their requests run as separate threads in one process — but the GVL lets only one thread execute Ruby at any instant. If the first request does CPU-bound work, the others run only in the gaps. They interleave on a single core instead of running in parallel, so each one’s latency goes up.

The GVL lets only one thread run Ruby at any instant.

To work around this, most Ruby servers fork the application: each worker runs in its own process, with its own memory and its own GVL. Our two users can now be served in parallel by two workers. This works — but it introduces a new trade-off: memory.

Because each forked worker has its own copy of the application, total memory usage goes up. Servers soften this by preloading the application before forking, so workers share those initial pages via Copy-on-Write. But that sharing is only a starting point: as each worker allocates objects, grows caches, and runs GC, its pages diverge and its footprint grows independently.

This is the trade-off Ractors let us sidestep. They run in parallel within a single process, so we get true parallelism without forking a full copy of the app per worker.

Testing the theory on a real application

That’s the theory. To see whether it holds outside microbenchmarks, we decided to put it to the test with an experiment: could we serve requests in a real Rails application using a Ractor-compatible web server, Kino?

We picked Basecamp’s Writebook as an ideal candidate: it’s a standard Rails application, well written and small enough. The questions we wanted to answer were:

Is this experiment even doable, or are Ractors still fundamentally incompatible with Rails?

How much memory could we save with a pool of N Ractors versus a clustered Puma setup with N workers?

Does latency increase?

With the help of an AI agent, we worked our way toward making a few endpoints Ractor-safe:

GET /up — A super lightweight endpoint that just returns a green webpage.

GET / — The home page after setting up Writebook. It’s similarly lightweight, but exercises ERB rendering and executes a couple of DB queries.

POST /first_run — The heaviest endpoint of the three: multiple DB queries, markdown rendering, image upload and analysis, and HTML sanitization.

We selected these endpoints so we could gradually see whether latency increased depending on the type of computation.

Multiple changes had to be made to let Writebook serve requests in a Ractor — inside gems, the Rails framework, and Writebook itself. A few native gem extensions were deliberately left Ractor-incompatible so we could measure the performance tax we pay when using an escape hatch, Ractor Dispatch, which lets us dispatch work to the main Ractor.

The source code of this experiment and the benchmarks are available in this repository if you’d like to run them on your own machine. Note that none of this is meant to be pretty or permanent — it’s just an experiment to gather numbers.

Results

Memory

Memory usage scaling with N: puma-cluster grows while a Ractor pool stays flat, reaching nearly 7× less memory at N=8.

The memory savings compounded as we increased the number of Ractors / Puma workers. Measured as PSS (which fairly accounts for memory shared between forked workers), a pool of 8 Ractors used nearly 7× less memory than 8 forked Puma workers serving the same load.

Latency

Latency for GET /up: base and Ractor are virtually identical.

The latency difference on the most trivial endpoint is, unsurprisingly, virtually identical.

Latency for GET /: a couple of DB queries are dispatched to the main Ractor.

Things get more interesting on an endpoint that uses the Ractor Dispatch escape hatch, where a couple of DB queries are funneled to the main Ractor. The latency difference goes up slightly, but in absolute terms it’s negligible.

Latency for POST /first_run: most of the overhead comes...

ractor rails memory ractors latency ruby

Related Articles