Ruby & Rails

Ruby 4.0: ZJIT, Ruby Box, and Why YJIT Stays

Ruby 4.0 shipped on Christmas Day with a new JIT compiler that is not yet the fast one, and an experimental answer to loading two versions of the same gem.

Two JIT compilers side by side beside two sealed isolated namespace boxes

The short version

  • Ruby 4.0 released on 25 December 2025, thirty years after Ruby’s first public release.
  • ZJIT is a new method-based JIT compiler and is not yet as fast as YJIT. Keep YJIT in production; the stated goal is ZJIT overtaking it in 4.1.
  • Ruby::Box is an experimental namespace isolation mechanism, gated behind RUBY_BOX=1. Genuinely interesting, not yet production infrastructure.
  • Despite the major version number, this is not a compatibility break in the way Ruby 2 to 3 was. Most applications upgrade uneventfully.

Ruby 4.0 arrived on Christmas Day 2025, keeping the tradition and marking thirty years since Ruby’s first public release. The version number is the largest jump in a decade and the upgrade is, for most applications, one of the quieter ones — which is worth saying plainly, because a major version number invites a fear of breakage that this release does not really justify.

What it does contain is two genuinely significant experiments and a redesign of Ruby’s parallelism API.

ZJIT: a second compiler, deliberately not the default

ZJIT is a new just-in-time compiler from the same team that built YJIT. It is written in Rust, requires Rust 1.85.0 or later to build, and is enabled with the --zjit flag, the RUBY_ZJIT_ENABLE environment variable, or a call to RubyVM::ZJIT.enable.

The interesting part is the honesty of the release notes. ZJIT is faster than the interpreter and slower than YJIT, and the Ruby team explicitly advise against deploying it in production for now. Shipping a headline feature with that caveat attached is unusual and it is the right call.

Why build a second JIT at all

The two compilers take different approaches. YJIT uses lazy basic block versioning, compiling small units of code as execution paths are discovered, which produces excellent results and a codebase that is difficult for outsiders to contribute to.

ZJIT uses a more conventional method-based strategy with a proper intermediate representation. That is a more familiar architecture, more amenable to standard optimisation passes, and considerably more approachable for people who want to work on compilers without first learning a bespoke design. The long-term argument is about the ceiling and about who can help raise it, not about today’s benchmark numbers.

The stated goal is for ZJIT to be faster than YJIT and production-ready in Ruby 4.1.

What to do about JIT today. If you are not already running YJIT in production, that is the change worth making — it is mature, it is a single flag, and it typically delivers a meaningful improvement on real Rails workloads. Try ZJIT in a benchmark environment out of interest, and revisit it at 4.1.

One related removal: --rjit is gone, with the third-party JIT API moved out to its own repository. RJIT was always an experiment and its departure is uncontroversial.

Ruby::Box: isolated namespaces

The more conceptually interesting feature, and the more experimental. Ruby::Box provides separation of definitions within a single Ruby process. It is disabled unless you set RUBY_BOX=1, at which point the Ruby::Box class becomes available.

Definitions loaded inside a box are isolated to that box. That covers monkey patches, changes to global and class variables, class and module definitions, and loaded Ruby and native libraries.

The immediate use case people reach for is loading two versions of the same gem simultaneously:

box1 = Ruby::Box.new
box1.require("some_gem", version: "1.0")

box2 = Ruby::Box.new
box2.require("some_gem", version: "2.0")

Anyone who has been trapped by a dependency conflict where two libraries require incompatible versions of a third will recognise why this matters. Beyond that, it offers a route to testing a library upgrade without a full migration, running legacy code alongside modern dependencies, and containing third-party code that monkey-patches core classes.

The caveats are real. It is experimental, it is opt-in via an environment variable, the ecosystem has not adapted to it, and native extension behaviour across boxes is exactly the kind of area where the sharp edges will be found. Treat it as a direction of travel rather than something to build on this year.

Ruby 4.0 is a release about the next few years rather than the next few months. Both headline features are labelled experimental, and both are pointing somewhere worth going.

Ractor, redesigned

Ractors — Ruby’s answer to true parallelism — got a communication API overhaul. The previous pattern of creating a Ractor, calling Ractor.yield inside it and retrieving values with ractor.take is replaced by an explicit Ractor::Port abstraction.

The new API is clearer about what is being sent where, which matters in a model where the whole point is that state is not shared. Ractors remain a specialist tool that most Rails applications will not touch, but the redesign makes them meaningfully easier to reason about for the workloads that suit them.

The smaller changes worth knowing

  • Object allocation is substantially faster — reported at more than double without JIT and close to four times with JIT enabled. For allocation-heavy Ruby code, which is most Ruby code, this is a free improvement.
  • Set is now a core class rather than a standard library addition requiring a require.
  • Array#rfind finds the last matching element without the allocation that reverse_each.find incurs.
  • Ruby::VERSION and Ruby::DESCRIPTION exist as real constants on a proper Ruby module.
  • Socket timeout behaviour is unified so IO::TimeoutError is raised consistently where previously the exception varied by situation.

What this means for Rails applications

The practical impact on a typical Rails application is modest in the short term and worth understanding in context.

The allocation improvements are the part you will feel. Rails allocates heavily — every request builds a large number of short-lived objects across routing, Active Record and view rendering — so a general improvement in allocation cost shows up across the board rather than in one hot spot. It is not a step change, and it is free.

ZJIT changes nothing for you today, and that is by design. YJIT remains the answer for production, and if your application is not running it, the gap between no JIT and YJIT is far larger than any difference between the two compilers will be at 4.1.

Ruby::Box is the one to watch rather than adopt. The dependency conflicts it could eventually resolve are a real recurring tax on long-lived Rails applications, particularly around gems that monkey-patch core classes. Whether it becomes practical infrastructure depends on how the ecosystem responds over the next couple of releases, and that is not a bet to place yet.

The wider point is that Ruby’s performance story has become genuinely credible over the last few years, and the remaining bottleneck in most Rails applications was never the interpreter. Profiling before optimising remains better advice than any language version.

Should you upgrade

For most applications, yes, on the normal schedule for a new Ruby release: wait for your key gems to confirm support, upgrade in a branch, run the full suite, and watch for deprecation warnings.

The major version number is doing less work here than it appears to. This is not the Ruby 2 to 3 transition with its keyword argument changes; the compatibility surface is comparatively small. The allocation performance improvements alone usually justify the upgrade, and both experimental features are opt-in, so upgrading does not commit you to either.

The one thing to be deliberate about is JIT configuration. Upgrading to Ruby 4.0 does not change your JIT — YJIT remains the production-ready choice and enabling ZJIT is an explicit decision you should make in a benchmark environment first.

Boolean Solutions experience with Ruby upgrades

We run Ruby and Rails applications in production and we handle version upgrades for clients regularly. The pattern that keeps them boring is unglamorous: upgrade one component at a time, keep the test suite trustworthy, and read the deprecation warnings on the previous version rather than discovering them on the new one.

Teams that fall several versions behind face a much larger job than the sum of the individual upgrades, because the gem ecosystem moves underneath them at the same time. If you are on an older Ruby and weighing up the path forward, we are happy to look at what is involved.

Further reading

Written by

Udit Mittal

Founder at Boolean Solutions. Twenty years of building and rescuing web, mobile and AI products for SaaS companies and startups — and writing down what actually worked.

Get in touch