Ractors and Ruby Box in Ruby 4: What Do They Mean for Rails?

Ruby 4.0 introduced two fascinating runtime capabilities:

  • Ractors, significantly improved for parallel execution
  • Ruby Box, an experimental mechanism for isolating definitions inside one Ruby process

For a Rails developer, the obvious question is:

Can I take my existing Rails application and simply add Ractors and Ruby Box to make it faster or more scalable?

The answer is not yet that simple.

Ractors can be extremely useful for carefully isolated CPU-heavy work, but a conventional Rails application is deeply interconnected through global state, constants, classes, ActiveSupport, ActiveRecord, gems, configuration and caches.

Ruby Box is a completely different concept. It is not primarily a parallelism mechanism. It provides in-process isolation of definitions and loaded code, with potential applications such as running multiple application versions in one Ruby process. Ruby 4.0 documents it as experimental.

Let’s look at both from a Rails perspective.


1. First: what problem does a Ractor solve?

A normal Ruby thread looks roughly like this:

Rails process
├── Thread 1
├── Thread 2
├── Thread 3
└── Thread 4
└── same Ractor / same GVL

Threads within a Ractor still share that Ractor’s GVL, so they don’t execute Ruby code in parallel with one another.

Ractors change the model:

Rails process
├── Ractor A ── GVL ── Thread(s)
├── Ractor B ── GVL ── Thread(s)
└── Ractor C ── GVL ── Thread(s)

Different Ractors can execute Ruby code in parallel on different CPU cores. Ruby 4.0 also reduced internal contention and introduced Ractor::Port for communication.

That makes Ractors especially interesting for CPU-bound work.


2. What should NOT be your first Ractor experiment?

Suppose you have:

class ReportsController < ApplicationController
  def show
    @report = Report.generate
  end
end

It is tempting to write:

def show
  r = Ractor.new do
    Report.generate
  end

  @report = r.value
end

This is exactly the kind of approach that exposes the biggest problem.

A Rails application has a huge amount of shared framework state.

For example:

Rails
├── ActiveSupport
├── ActiveRecord
├── Zeitwerk
├── configuration
├── caches
├── logging
├── autoloading
├── class/module definitions
└── gems

Ractors deliberately restrict access to non-shareable objects across Ractors.

The Ruby documentation says that most objects are unshareable and communication between Ractors is intended to happen through shareable objects or message passing.

That makes a normal Rails application a poor candidate for simply wrapping arbitrary Rails calls inside Ractor.new.

There has also been a real Rails issue demonstrating Ractor::IsolationError when attempting to instantiate or use Rails application state from a non-main Ractor.


3. The better idea: use Ractors around isolated computation

Instead of:

Ractor
Entire Rails application

think:

Rails
├── request
├── database work
└── isolated CPU calculation
Ractor

For example, imagine a report containing millions of values.

class ReportCalculator
  def self.calculate(numbers)
    numbers.sum { |n| expensive_calculation(n) }
  end

  def self.expensive_calculation(n)
    # CPU-heavy calculation
    n ** 3
  end
end

You could partition the data:

chunks = numbers.each_slice(10_000).to_a

ractors = chunks.map do |chunk|
  Ractor.new(chunk) do |values|
    values.sum { |n| n ** 3 }
  end
end

result = ractors.sum(&:value)

The important architectural boundary is:

Rails
│ plain data
Ractor 1 ── CPU work ──┐
Ractor 2 ── CPU work ──┼──→ results
Ractor 3 ── CPU work ──┘
Rails

This is much more promising.

The Ractors don’t need to manipulate:

ActiveRecord::Relation
Rails.application
ActiveSupport::Cache
Controller
request
response

They receive isolated data and return isolated results.


4. A practical Rails use case: analytics

Imagine:

orders = Order
.where(created_at: 30.days.ago..)
.pluck(:amount)

The database query happens normally.

Then:

chunks = orders.each_slice(50_000).to_a

ractors = chunks.map do |chunk|
  Ractor.new(chunk) do
    {
      total: chunk.sum,
      average: chunk.sum.to_f / chunk.length
    }
  end
end

results = ractors.map(&:value)

total = results.sum { |r| r[:total] }

The database remains Rails’ responsibility.

The CPU-heavy aggregation becomes parallel work.

That is the mental model I’d recommend:

Use Rails for orchestration; use Ractors for isolated computation.


5. Another good candidate: document/image processing

Suppose your application performs CPU-heavy transformations:

PDF
parse
transform
calculate
generate result

Instead of letting one Ruby execution stream process everything:

Rails
└── CPU-heavy processing

you can potentially build:

                    Rails
                      │
               Job / Service
                      │
          ┌───────────┼───────────┐
          ▼           ▼           ▼
       Ractor      Ractor      Ractor
         │           │           │
       file A      file B      file C
          └──────────┬──────────┘
                     ▼
                   result

The same principle applies to:

  • compression
  • large JSON transformations
  • encryption-related computation
  • parsing
  • ranking/scoring
  • simulations
  • large in-memory calculations

The exact benefit depends heavily on whether the work is CPU-bound and whether the cost of copying/moving data outweighs the parallelism benefit.

Ruby’s Ractor documentation explicitly notes that unshareable objects may be copied or moved between Ractors, so data-transfer overhead must be considered.


6. Ractor is not a replacement for ActiveJob

It is important not to confuse these abstractions.

For example:

SomeJob.perform_later(order.id)

and:

Ractor.new(...)

solve different problems.

ActiveJob/Sidekiq/GoodJob/etc. solve background job execution and process-level application architecture.

Ractors solve parallel execution inside one Ruby process.

You could potentially combine them:

Rails
Background Job
Ruby Process
├── Ractor 1
├── Ractor 2
├── Ractor 3
└── Ractor 4

But this is an advanced optimization, not the default architecture.


7. So where does Ruby Box fit?

Ruby Box is much less about CPU parallelism.

Its purpose is definition isolation.

Suppose you have:

class User
def role
"admin"
end
end

Now imagine loading another piece of code that reopens User:

class User
def role
"guest"
end
end

Normally, that’s a global change to the Ruby process.

Ruby Box lets those definitions exist in separate boxes.

Conceptually:

Ruby process
├── Main box
│ └── User#role → "admin"
└── Box B
└── User#role → "guest"

Ruby’s documentation describes this as isolation of class/module definitions, monkey patches, constants, global/class variables and loaded Ruby/native libraries.

This is a very different problem from Ractors.


8. A simple Ruby Box example

Ruby Box must be enabled at process startup:

RUBY_BOX=1 ruby app.rb

Setting the variable after Ruby has already started does not enable it.

Then:

box = Ruby::Box.new
box.require("./legacy_user.rb")

Suppose legacy_user.rb contains:

class User
def role
"legacy"
end
end

The definition is loaded into the box.

Conceptually:

Main box
└── User
Legacy box
└── User
└── role → "legacy"

The definition in the box is isolated from the corresponding definition in other boxes. Ruby’s documentation demonstrates this with constants, classes and methods.


9. This has a fascinating Rails use case: blue-green application versions

This is one of the use cases Ruby itself proposes.

Imagine:

One Ruby process
┌─────────────────────────────┐
│ Ruby Process │
│ │
│ Box A │
│ Rails App v1 │
│ │
│ Box B │
│ Rails App v2 │
└─────────────────────────────┘

Ruby 4.0 explicitly lists running web-app boxes in parallel as a potential blue-green deployment use case.

Theoretically, this gives you the ability to have:

/app-v1
/app-v2

loaded in separate definition environments inside the same Ruby process.

Then requests could be directed to:

traffic
├──→ Box A
└──→ Box B

This could eventually enable interesting deployment and migration strategies.

But there is a huge caveat.


10. Ruby Box is experimental

This is not currently something I’d take into a normal Rails production deployment simply because Ruby 4 has it.

The official documentation lists known issues, including:

  • native extension installation problems
  • require 'active_support/core_ext' potentially failing
  • limitations around methods defined in a box being called by built-in Ruby methods

and other TODO items.

That is especially important for Rails.

Rails isn’t a small collection of isolated classes.

It has a large dependency graph:

Rails
├── ActiveSupport
├── ActiveRecord
├── ActionPack
├── Zeitwerk
├── Rack
├── Bundler
├── native extensions
└── hundreds of possible gems

Isolating an entire Rails application therefore involves considerably more than:

box = Ruby::Box.new
box.require("app")

11. Ruby Box could be more interesting for development and testing first

One of Ruby’s proposed Ruby Box use cases is isolating tests that perform monkey patches.

Consider:

class String
def special
"patched"
end
end

Normally this contaminates the process.

A box can potentially isolate that modification:

Test process
├── Main box
│ └── normal String
├── Test Box A
│ └── patched String
└── Test Box B
└── different String definition

Ruby itself lists isolated test execution as an expected use case.

For a large test suite, this is an interesting direction.


12. How I would use Ractors in a new Rails application

I wouldn’t architect the entire application around Ractors.

Instead:

                    Rails
                      │
      ┌───────────────┼────────────────┐
      │               │                │
   HTTP/API        ActiveRecord     Background Jobs
      │
      │
      └────── CPU-heavy service ───────┐
                                       │
                          ┌────────────┼────────────┐
                          ▼            ▼            ▼
                       Ractor       Ractor       Ractor

Keep Rails state out of the Ractors wherever possible.

Design explicit boundaries:

input = {
values: values,
options: options
}

rather than:

ractor = Ractor.new do
Order.where(...)
end

The first is an isolated computation.

The second makes the Ractor responsible for Rails state.

That’s where the complexity explodes.


13. How I would introduce Ractors into an existing Rails app

Start with one measurable CPU bottleneck.

For example:

Before
request
large calculation
1 CPU core
response

Then extract:

class PricingCalculator
def self.calculate(input)
# pure Ruby calculation
end
end

Make it as pure as possible:

result = PricingCalculator.calculate(
prices: prices,
rules: rules
)

Then experiment with:

Ractor.new(input) do |data|
PricingCalculator.calculate(data)
end

Benchmark both:

single-threaded
vs
multiple Ractors

Don’t assume parallelism automatically means faster execution.

You need to measure:

  • CPU time
  • wall-clock time
  • memory usage
  • object copying
  • Ractor startup
  • throughput
  • latency

14. Rails architecture: where each feature fits

A useful mental model is:

                    Rails
                      │
     ┌────────────────┼─────────────────┐
     │                │                 │
     ▼                ▼                 ▼
   Web              Data             Jobs
     │                │                 │
     └────────────────┴────────┐        │
                               ▼        ▼
                       Application services
                               │
                       CPU-heavy workload
                               │
                         ┌─────┴─────┐
                         ▼           ▼
                      Ractor      Ractor

Ruby Box sits at a different architectural layer:

Ruby Process
├── Main / Application Box
├── Application Box A
└── Application Box B

So:

Ractor = parallel execution

while:

Ruby Box = definition/environment isolation

They solve different problems.


15. The big Rails limitation today

This is the part worth remembering.

A conventional Rails application is built around a substantial amount of shared application state.

That doesn’t fit naturally with Ractor’s isolation model.

There has been an explicit Rails issue requesting Ractor support and that issue was closed as “not planned.” The discussion showed Ractor::IsolationError arising from Rails class-level state.

https://github.com/rails/rails/issues/51543

That doesn’t mean Rails can never become Ractor-friendly.

It means:

Don’t interpret Ruby 4’s Ractor improvements as “Rails is now automatically Ractor-safe.”

Those are two different layers.

Ruby’s runtime may support the concurrency primitive while the framework ecosystem still has architectural work to do.

Read for more info: https://discuss.rubyonrails.org/t/ractor-safe-rails/91277


16. The practical strategy for a Rails developer

For an existing Rails application:

1. Find CPU-bound code
2. Extract it from Rails state
3. Make inputs/outputs explicit
4. Benchmark it
5. Try Ractors
6. Measure copying + memory
7. Keep the rest of Rails unchanged

For a new application:

Rails
thin controllers
application services
pure computation
Ractor boundary

That architecture gives you a much better chance of benefiting from parallel Ruby.

For Ruby Box:

Development/testing first
isolated definitions
experimentation
specialized deployment scenarios

rather than immediately attempting:

"Let's run the whole Rails app in 10 Ruby Boxes."

Final takeaway

Ruby 4 did something more interesting than simply making threads faster.

It is giving Ruby developers more explicit runtime tools:

Ractor
parallel Ruby computation
Ruby Box
isolated Ruby definitions
YJIT / ZJIT
faster execution
GC/runtime improvements
less overhead

For Rails, however, the winning strategy isn’t:

“Convert Rails to Ractors.”

It is:

“Keep Rails responsible for application orchestration and isolate carefully chosen CPU-heavy computations behind Ractor boundaries.”

And Ruby Box is even more experimental.

Its long-term Rails potential may actually be more architectural than performance-oriented: isolating application versions, tests, plugins, or dependency environments inside one Ruby process.

The exciting thing is that Ruby 4 gives us primitives that make these designs possible.

The engineering challenge is deciding where the boundary belongs.

That is ultimately the same lesson we’ve been following through this series:

Ruby gives us abstractions. Understanding the runtime lets us decide when to cross them.

A useful next post in this series would be “Ractor vs Thread vs Process in Rails: when should a senior Rails developer choose each?” – with real benchmarks, memory/CPU trade-offs and a Sidekiq/Puma/Ractor architecture comparison.

Unknown's avatar

Author: Abhilash

Hi, I’m Abhilash! A seasoned web developer with 15 years of experience specializing in Ruby and Ruby on Rails. Since 2010, I’ve built scalable, robust web applications and worked with frameworks like Angular, Sinatra, Laravel, Node.js, Vue and React. Passionate about clean, maintainable code and continuous learning, I share insights, tutorials, and experiences here. Let’s explore the ever-evolving world of web development together!

Leave a comment