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.
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.
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.
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.
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 from work dispatched to the main Ractor.
Latency for POST /first_run: most of the overhead comes from work dispatched to the main Ractor.

Finally, on the heaviest endpoint — where many operations are performed to set up a brand-new Writebook instance — latency clearly increases. It’s important to note, however, that most of the overhead comes from dispatching work to the main Ractor multiple times, because of native extensions that aren’t Ractor-compatible. Each dispatch incurs a full cross-Ractor round-trip — making the block shareable, then passing it and its result across the boundary — which ultimately increases latency.

As the Ruby ecosystem’s support for Ractors matures, this performance tax will fade away.

Concluding the experiment

This experiment confirmed that the benefit of Ractors isn’t only theoretical, and that there’s a viable path for Rails to support them. Of course, none of this is free. Making a Rails application Ractor-safe means freezing state, sharing objects explicitly, and — for now — funneling things like database queries to the main Ractor as we figure out how each Ractor can manage its own database connections.

This undeniably adds complexity to the framework, but we’re taking it on deliberately, because we believe the payoff is worth it and most of today’s rough edges are temporary.

Bringing Ractors to Rails

Our first milestone is deliberately modest: generate a brand-new Rails app, scaffold a resource, and serve its requests inside a Ractor. It sounds small, but a freshly scaffolded app already exercises a surprising amount of the framework — routing, the request/response cycle, view rendering, translation, database queries, the asset pipeline, and the many Active Support helpers.

We’re fixing all of these issues and are nearing the end. Getting all of that Ractor-safe is the foundation everything else builds on, and it puts Ractors within reach of anyone who can run rails new.

But it’s only the beginning. Beyond that first milestone, there’s a long list of work ahead — for instance, making Rails Ractor-safe in a non-eager-loaded environment so it works during local development, or extending Ractor support to the rest of the framework, such as Active Job, Active Storage, and Action Cable. There’s a lot left to do, and we’re excited to bring the rest of Rails into the Ractor-age.