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 ──┼──→ resultsRactor 3 ── CPU work ──┘ │ ▼Rails
This is much more promising.
The Ractors don’t need to manipulate:
ActiveRecord::RelationRails.applicationActiveSupport::CacheControllerrequestresponse
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" endend
Now imagine loading another piece of code that reopens User:
class User def role "guest" endend
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.newbox.require("./legacy_user.rb")
Suppose legacy_user.rb contains:
class User def role "legacy" endend
The definition is loaded into the box.
Conceptually:
Main box│└── UserLegacy 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.newbox.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" endend
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:
Beforerequest ↓large calculation ↓1 CPU core ↓response
Then extract:
class PricingCalculator def self.calculate(input) # pure Ruby calculation endend
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-threadedvsmultiple 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 code2. Extract it from Rails state3. Make inputs/outputs explicit4. Benchmark it5. Try Ractors6. Measure copying + memory7. 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 computationRuby Box ↓isolated Ruby definitionsYJIT / ZJIT ↓faster executionGC/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.