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.

The above code can be Optimized. Check: https://railsdrop.com/optimization-fix-the-memory-heavy-ruby-operation/


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.

Ruby Beyond CRuby: Why JRuby and TruffleRuby Exist? What Ruby 4.0 Really Changed

In my previous posts, I looked at how Ruby code eventually reaches the VM, native runtime and CPU.

That naturally leads to another question:

Why is there more than one Ruby?

Most Ruby developers use CRuby/MRI and may never think about it. But Ruby is a language specification, not a single runtime implementation.

Today we have several implementations, with two particularly interesting alternatives:

JRuby – Ruby implemented on the JVM.

TruffleRuby – Ruby implemented using the GraalVM/Truffle ecosystem.

And then there is the increasingly interesting question:

Did Ruby 4 finally remove the GIL and solve Ruby’s performance/scalability problems?

Not exactly.

Let’s look at why these implementations exist and where Ruby 4.0 stands today.


Ruby is a language, not necessarily an implementation

When I write:

class User
  def greet
    "Hello"
  end
end

I’m writing Ruby language semantics.

But somebody has to implement those semantics.

There is no requirement that the implementation must be written in C.

So we can have:

                    Ruby Language
                         │
          ┌──────────────┼──────────────┐
          ▼              ▼              ▼
       CRuby           JRuby       TruffleRuby
          │              │              │
          ▼              ▼              ▼
       C / VM           JVM       GraalVM / Truffle

All three attempt to behave like Ruby while using very different execution technologies.


Why was JRuby created?

JRuby’s fundamental idea was:

What if Ruby could run on the JVM and take advantage of everything the JVM already provides?

The JVM already has:

  • mature garbage collection
  • JIT compilation
  • highly optimized threading
  • profiling
  • excellent runtime tooling
  • enormous Java libraries
  • mature production infrastructure

Instead of building all of that from scratch, JRuby puts a Ruby implementation on top of the JVM.

Ruby code
    │
    ▼
JRuby
    │
    ▼
JVM
    │
    ├── JIT
    ├── GC
    ├── Threads
    └── Java libraries
    │
    ▼
CPU

JRuby explicitly aims to provide Ruby without a global interpreter lock, true parallelism and integration with Java. (GitHub)

That makes JRuby particularly interesting for applications where Ruby needs to coexist with Java infrastructure.


JRuby’s biggest advantage: true parallel Ruby threads

In CRuby, ordinary Ruby threads are native threads, but Ruby execution within a single Ractor is constrained by its GVL.

JRuby takes a different approach.

Multiple Ruby threads can execute Ruby code concurrently because there is no equivalent global interpreter lock preventing Ruby threads from running in parallel.

Conceptually:

CRuby

Thread 1 ──┐
Thread 2 ──┼──→ GVL ──→ Ruby execution
Thread 3 ──┘

Whereas:

JRuby

Thread 1 ─────────────→ CPU Core 1
Thread 2 ─────────────→ CPU Core 2
Thread 3 ─────────────→ CPU Core 3

That can be extremely valuable for CPU-heavy or highly concurrent workloads.

JRuby 10 also moved to Java 21 and made invokedynamic optimization the default, taking advantage of more modern JVM capabilities. (blog.jruby.org)


Why TruffleRuby?

TruffleRuby comes from a completely different idea.

Instead of saying:

“Let’s implement Ruby using the JVM.”

the Truffle approach essentially says:

“Let’s implement Ruby on a framework designed to build highly optimizing language runtimes.”

TruffleRuby uses the Truffle framework and GraalVM.

Ruby source
     │
     ▼
TruffleRuby
     │
     ▼
Truffle AST / runtime
     │
     ▼
Graal compiler
     │
     ▼
Optimized machine code
     │
     ▼
CPU

The interesting part is that Truffle/Graal can observe running code and aggressively specialize and optimize it.

TruffleRuby’s project explicitly targets high performance for Ruby workloads, parallel execution without a global interpreter lock, native extensions and interoperability with Java and other languages in the GraalVM ecosystem. (GitHub)

GraalVM Doc: https://www.graalvm.org/latest/introduction/


TruffleRuby and JRuby solve a similar problem differently

This distinction is important.

CRubyJRubyTruffleRuby
Main technologyC + Ruby VMJVMTruffle + GraalVM
GVL for normal Ruby threadsYesNoNo
Parallel Ruby threadsLimited by GVLYesYes
JVM ecosystemNoExcellentExcellent
JITYJIT/ZJITJVM JITGraal
Native extensionsExcellentDifferent approachMany C extensions supported
StartupExcellentGenerally slowerDepends on configuration
Warm-upLowHigherHigher
Peak performanceVery goodVery goodExcellent for suitable workloads

The important lesson is:

There isn’t one universally “best Ruby”.

The optimal runtime depends on the workload.


Now the big question: Does Ruby 4 remove the GIL?

No.

And there is an important terminology correction.

CRuby generally calls it the GVL – Global VM Lock.

Ruby 4.0 did not remove it from normal Ruby threads.

Ruby’s documentation states that threads within the same Ractor share a ractor-wide GVL and therefore cannot execute Ruby code in parallel with each other. Threads belonging to different Ractors can execute in parallel.

Ractors are designed to provide parallel execution of Ruby code without thread-safety concerns. (Ruby Documentation)

So:

                    CRuby 4.0
                       │
          ┌────────────┴────────────┐
          ▼                         ▼
      Ractor A                  Ractor B
          │                         │
     Thread 1                   Thread 1
     Thread 2                   Thread 2
          │                         │
        one GVL                  one GVL
          │                         │
          └──────────┬──────────────┘
                     │
               parallel execution

This is a major distinction.

Ruby 4 did not say:

“GVL is gone.”

It moved Ruby’s concurrency model further toward Ractor-based parallelism.


Ruby 4.0 significantly improved Ractors

Ruby 4.0 invested heavily in reducing the contention that previously limited Ractor scalability.

The release notes specifically mention improvements such as:

  • lock-free structures for frozen strings and the symbol table
  • fewer locks in method-cache lookups
  • faster instance-variable access
  • reduced allocation contention
  • reduced CPU cache contention
  • fewer locks around object_id
  • fixes for deadlocks and GC races involving Ractors (Ruby)

This is a much deeper improvement than simply deleting one lock.

The architecture is moving toward:

Before

Ractor ──┐
Ractor ──┼── shared internal state ── contention
Ractor ──┘


Ruby 4 direction

Ractor A ── mostly independent state
Ractor B ── mostly independent state
Ractor C ── mostly independent state

             ↓

       less lock contention
       less cache contention
       better parallelism

Ruby 4 also introduced Ractor::Port as a new synchronization mechanism and added shareable Proc/lambda APIs.


Ruby 4’s bigger performance story: YJIT and ZJIT

Ruby 4.0 introduced ZJIT, the next-generation JIT compiler after YJIT.

The interesting part is that Ruby now has two very different JIT stories:

                 Ruby 4
                   │
          ┌────────┴────────┐
          ▼                 ▼
        YJIT               ZJIT
      mature              new
      production          experimental
          │                 │
          ▼                 ▼
    native machine code   native code

Ruby’s own release announcement is very clear:

ZJIT is faster than the interpreter, but not yet as fast as YJIT.

Ruby 4.0 therefore recommends experimentation rather than production deployment for ZJIT.

ZJIT is intended to raise Ruby’s performance ceiling through larger compilation units and SSA-based intermediate representation, while also making the compiler architecture more approachable for outside contributors.

So Ruby 4 did not replace YJIT with a magically faster JIT overnight.

It started building the next generation.


Ruby 4 also improved the GC and object system

Some of the most interesting Ruby 4 changes aren’t visible from Ruby syntax at all.

Ruby 4.0 includes improvements such as:

• Independent growth of GC heaps for different size pools
• Faster sweeping of pages containing large objects
• Faster Class#new
• Improved instance-variable storage
• Less GC overhead from write barriers
• Better handling of embedded large Bignums
• Faster object_id/hash operations

These changes target allocation, memory consumption, GC work, object access and general runtime overhead. (Ruby)

For a Rails application, these details matter because a significant amount of application work eventually becomes:

allocate
object lives
object becomes unreachable
GC
CPU + memory bandwidth

Improving that pipeline can produce real application-level benefits without changing your Rails code.


Ruby 4’s interesting new feature: Ruby Box

Ruby 4.0 also introduced an experimental feature called Ruby Box.

It allows definitions and changes to be isolated from other boxes.

That includes things like:

  • monkey patches
  • class/module definitions
  • class/global variables
  • loaded libraries

One proposed use case is running multiple isolated application versions in the same Ruby process – for example, blue/green deployment scenarios.

Conceptually:

Ruby Process
├── Box A → Application version A
├── Box B → Application version B
└── Box C → Experiment

This is quite different from the normal Ruby process model and could become more interesting over time.


So did Ruby 4 “fix Ruby performance”?

No single release can be described that way.

Ruby’s performance problem has never been just one problem.

There are several:

Ruby performance
├── Interpreter overhead
├── Method dispatch
├── Object allocation
├── Garbage collection
├── Memory/cache behaviour
├── JIT compilation
├── Lock contention
└── Parallel execution

Ruby 4 improves several of these.

But each improvement has trade-offs.


Ruby 4: the best features

For an experienced Ruby/Rails developer, I would highlight these:

1. Better parallelism

Ractors are substantially more mature and have significantly less internal contention. (Ruby)

2. Better JIT direction

YJIT remains the mature choice, while ZJIT establishes a new JIT architecture with a higher long-term performance goal.

3. Runtime and GC improvements

Allocation, sweeping, object access and GC overhead have all received attention.

4. Ruby Box

A fascinating new isolation primitive that may eventually influence how long-running Ruby processes host multiple isolated applications.

5. Ecosystem maturity

Ruby 4 continues to preserve the programming model that makes Rails productive while the runtime underneath becomes increasingly sophisticated.


But Ruby 4 still has limitations

The biggest one is straightforward:

Normal Ruby threads still don’t provide unrestricted CPU parallelism inside one Ractor.

The GVL remains part of CRuby’s threading model. (Ruby Documentation)

There are also practical considerations around Ractors: code must respect Ractor isolation and shareability rules and not every gem or application architecture will naturally benefit from them.

And ZJIT is not yet a drop-in reason to turn off YJIT and deploy it everywhere; Ruby 4.0’s own release notes explicitly say it is not yet as fast as YJIT and recommend holding off on production use.


What about Ruby 4 vs JRuby and TruffleRuby?

This is where Ruby becomes particularly interesting.

                       Ruby
                        │
       ┌────────────────┼────────────────┐
       │                │                │
       ▼                ▼                ▼
     CRuby             JRuby        TruffleRuby
       │                │                │
       ▼                ▼                ▼
     C/VM              JVM        Graal/Truffle
       │                │                │
       ▼                ▼                ▼
     YJIT             JVM JIT        Graal JIT
       │                │                │
       ▼                ▼                ▼
    Ractors         real threads    real threads

CRuby’s advantage is its enormous compatibility, mature ecosystem, excellent startup characteristics and continued optimization of the standard implementation.

JRuby’s strength is the JVM: parallel Ruby threads and access to the Java ecosystem.

TruffleRuby’s strength is aggressive specialization and Graal-based optimization, with parallel Ruby execution and polyglot capabilities. Its maintainers report very high performance on appropriate benchmark workloads, though warm-up and compatibility remain practical considerations.


My conclusion as a Ruby developer

I think the most important change is not:

“Ruby 4 removed the GIL.”

It didn’t.

The more accurate statement is:

Ruby is steadily evolving from a primarily interpreter-centric runtime toward a highly optimized, JIT-driven, increasingly parallel execution platform.

The interesting evolution looks like this:

Old Ruby
   │
   ▼
Interpreter
   │
   ▼
GVL
   │
   ▼
Threads mostly for concurrency


Modern Ruby
   │
   ├── YJIT
   ├── ZJIT
   ├── better GC
   ├── better object representation
   ├── reduced lock contention
   └── Ractors
           │
           ▼
      parallel Ruby

And this is exactly why learning C and runtime internals is becoming more valuable.

When you understand memory, object allocation, GC, locks, CPU caches, JITs, threads and process boundaries, Ruby 4’s changes stop looking like a collection of release notes.

You start seeing the bigger picture:

The Ruby language hasn’t changed its philosophy of developer productivity. The runtime underneath it is becoming increasingly sophisticated at extracting performance from that high-level language.

As of August 2026, the current stable Ruby 4 branch is Ruby 4.0, with Ruby 4.0.6 released on July 14, 2026. (Ruby)

And that makes this a perfect point in the series to go one level deeper:

What actually happens inside a Ractor, how its GVL differs from the old “global” model and how Ruby can execute Ruby code in parallel without simply removing thread safety?

Happy Rubying!

Ruby’s Triple-Dot Operator: Argument Forwarding from 2.7 to 4.0

If you’ve ever written a method whose entire job is to pass its arguments straight through to another method, you’ve felt this pain:

class Repository
def find(id, *args, **kwargs, &block)
connection.find(id, *args, **kwargs, &block)
end
end

Three parameter types, three splat operators, zero information conveyed. Ruby 2.7 solved this with a single token: ...

The problem it solves

... is Ruby’s argument forwarding shorthand. It captures everything passed to a method – positional args, keyword args, and a block – and lets you re-throw it at another method without naming a single one of them.

def find(...)
connection.find(...)
end

Same behavior as the splat version above, minus the noise. It behaves like a bare super with parens: “take what I got, send it onward, unchanged.”

Ruby 2.7: the baseline

... shipped in Ruby 2.7 (Dec 2019) with one hard restriction: it has to be the only parameter in the definition, and the only argument in the call. You can’t mix it with named params, and you can’t inspect or transform the forwarded values.

def log_call(...)
puts "calling..."
perform(...)
end
def perform(*args, **kwargs, &block)
block.call(args, kwargs)
end
log_call(1, 2, k: 3) { |a, k| p [a, k] }
# => [[1, 2], {k: 3}]

Clean, but rigid – useful only for pure pass-through wrappers (logging, memoization, decorators).

Ruby 3.0: leading arguments

Ruby 3.0 (Dec 2020) lifted the “must be alone” rule. You can now peel off one or more leading positional arguments before the ..., in both the definition and the call site:

def method_missing(name, ...)
send(:"do_#{name}", ...)
end

This is the textbook use case – method_missing needs the method name for dispatch logic, but everything else should flow through untouched:

def transform(a, ...)
process(a, ...)
end
def process(a, *args, **kwargs, &block)
[a, args, kwargs]
end
transform(1) # => [1, [], {}]
transform(1, 2, k: 3) # => [1, [2], {k: 3}]

This turned ... from a niche decorator trick into something you’d actually reach for in delegation-heavy code – Rails controllers forwarding to services, DSL builders, proxy objects.

Ruby 3.1: anonymous block forwarding

Ruby 3.1 (Dec 2021) split the block piece out on its own. If you only need to forward the block and want the positional/keyword args handled explicitly, use a bare &:

def perform(&)
execute(&)
end

No name required on either side. This composes with regular named params:

def retry_with(times:, &)
times.times { execute(&) }
end

Ruby 3.2: anonymous splat and double-splat

Ruby 3.2 (Dec 2022) completed the set, adding anonymous * and ** forwarding to match the anonymous & from 3.1:

def split_arguments(*, **)
pass_positional(*) # forwards only positional args
pass_keywords(**) # forwards only keyword args
end
split_arguments(1, 2, a: 3, b: 4)
# pass_positional(1, 2)
# pass_keywords(a: 3, b: 4)

This matters when a method needs to route positional and keyword args to different destinations – ... can’t do that, since it moves as one atomic bundle.

Ruby 3.3 / 3.4 / 4.0: no new syntax, better tooling

No further language changes landed for ... itself through 3.3, 3.4, or Ruby 4.0 (released Dec 2025). Two things worth knowing if you’re on current Ruby:

  • RBS gained first-class support for forwarding parameters in method type signatures in 2026 (def request: (...) -> Response), so type-checked codebases no longer have to erase forwarded signatures to untyped.
  • Ruby 4.0 changed splat semantics slightly: *nil no longer calls nil.to_a, and **nil no longer calls nil.to_hash. It’s not a ...-specific change, but if you forward keyword args that might legitimately be nil (e.g., **opts where opts defaults to nil), check this – it’ll raise instead of silently treating nil as {}.

Version cheat sheet

VersionReleasedAdds
2.7Dec 2019... – forwards all args + block, must be the sole parameter
3.0Dec 2020Leading arguments alongside ...: def foo(a, ...)
3.1Dec 2021Anonymous block forwarding: def foo(&); bar(&); end
3.2Dec 2022Anonymous splat/double-splat: def foo(*, **); bar(*); baz(**); end
3.3 – 4.02023 – 2025No new ... syntax; RBS forwarding types; *nil/**nil semantics changed in 4.0

If you’re targeting older runtimes: 2.7 and 3.0 are long past EOL, 3.1 EOL’d in 2025, and only 3.2+ receives active support as of this writing. Practically, any codebase forwarding arguments today should assume 3.2+ and use whichever granularity (..., &, *, **) fits the delegation.

Where I’d push back on using it

... is DRY, but it’s opaque. def foo(...) tells a reader nothing about the method’s actual contract – they have to chase the call chain to find out what foo accepts. For a pure pass-through utility (logging wrapper, method_missing dispatch, a thin repository shim), that opacity is the whole point and it’s the right call. For a public API boundary – a service object’s entry point, a gem’s documented interface – I’d argue explicit named parameters (or at minimum RBS/Sorbet signatures) win, because the signature is the documentation, and ... erases it at the exact place callers look first.

A second edge case: you can’t currently pass extra trailing arguments after a forwarded ... in a call (bar(extra, ...) is still restricted) – only leading ones. If you need to inject an argument after the forwarded set, you’re back to explicit *args, **kwargs, &block.

Rule of thumb: reach for ... (or &/*/**) at internal delegation boundaries where the wrapper genuinely adds no new arguments of its own. Keep explicit signatures at any boundary another engineer – or a type checker – needs to reason about without reading the delegate.

Happy Rubying!