· 6 min read ·

The Concurrency Problem Rails Never Fully Solved, and What the BEAM Would Actually Change

Source: lobsters

Sam Ruby’s recent post Rails on the BEAM is the kind of thing that sounds like a thought experiment until you start pulling on the thread. Running Rails on the Erlang virtual machine is a genuinely interesting idea, not because it’s easy or even fully practical today, but because the gap it’s trying to close is real and the BEAM’s answers to that gap are better than anything Ruby has managed to grow on its own.

The GVL Is Not the Whole Story, But It’s a Big Part

The standard critique of Ruby’s concurrency story starts with the Global VM Lock, or GVL (historically called the GIL). MRI Ruby, the reference implementation almost everyone runs in production, holds a lock around most Ruby execution. Two threads cannot run Ruby bytecode simultaneously in the same process. You can achieve parallelism by forking multiple OS processes, which is exactly what Puma and Unicorn do, but that comes with real costs: memory overhead per process, cold start latency, and the ceiling that comes from multiplying your database connection pool by your worker count.

Rails 8 addressed some of this with Solid Queue and Solid Cache, moving background jobs and caching into SQLite or Postgres rather than Redis-backed external processes. That’s a genuine improvement in operational simplicity. But it doesn’t change the underlying execution model. You’re still running N independent Ruby processes, each with its own memory space, each making its own database connections, each unable to share in-process state with its neighbors.

The BEAM’s answer is different in kind, not just in degree.

How the BEAM Actually Works

The BEAM (Bogdan/Björn’s Erlang Abstract Machine) is the runtime for Erlang and Elixir, and its design philosophy is that isolation and concurrency should be the default rather than an afterthought.

Each Erlang or Elixir “process” is not an OS process. It’s a lightweight coroutine maintained entirely by the BEAM scheduler, with its own heap, mailbox, and stack. The overhead is measured in microseconds and a few hundred bytes. Starting a million of them in a single VM is not unusual; it’s the expected use case.

# This is not shocking to a BEAM developer
for i <- 1..1_000_000 do
  spawn(fn -> receive do :go -> IO.puts(i) end end)
end

The scheduler is preemptive, based on a reduction count rather than wall-clock time, which means a single slow process cannot starve everything else in the VM. Each process gets garbage collected independently; there is no stop-the-world GC pause that freezes request handling across the whole application.

This is why Phoenix LiveView performs the way it does. Hundreds of thousands of active WebSocket connections can live comfortably in a single node because each one is a few-hundred-byte process, not a thread or a file descriptor being polled on an event loop.

What Rails Running on the BEAM Would Change

The appeal is direct: take Rails’ convention-over-configuration approach to routing, ActiveRecord’s database abstractions, the asset pipeline, the generators, all the productivity tooling that Rails developers have spent two decades building, and run it on a runtime that handles concurrency the way a web application actually needs.

The concurrency model transformation would be the most visible. A Rails controller action that today blocks a Puma thread while waiting on an external HTTP call would instead be a BEAM process that suspends and yields, freeing the scheduler to run other processes. Database queries would behave the same way. Connection pooling overhead would drop dramatically because you’re not multiplying connections by OS process count.

Fault tolerance is the less-discussed benefit. OTP’s supervisor tree model means a crashed request handler restarts automatically without taking down the web server. In Rails today, an unhandled exception in a background job typically requires the job to be retried at the queue level, with all the state serialization overhead that implies. On the BEAM, a failed process is just a failed process; its supervisor restarts it with fresh state immediately.

# OTP supervision: a crash in MyWorker restarts only MyWorker
defmodule MyApp.Supervisor do
  use Supervisor

  def init(:ok) do
    children = [
      {MyWorker, []},
      {AnotherWorker, []}
    ]
    Supervisor.init(children, strategy: :one_for_one)
  end
end

Hot code reloading, another BEAM primitive, would give Rails the ability to deploy without dropping connections. Phoenix already demonstrates this; upgrading a live system while maintaining active WebSocket connections is standard in Elixir deployments.

The Hard Problems

The challenges are also real, and hand-waving past them would be dishonest.

Ruby’s C extension ecosystem is enormous. Gems like pg, nokogiri, bcrypt, and hundreds of others contain native C code that calls directly into the MRI internals. None of that code can run on the BEAM without a complete rewrite or an FFI boundary that introduces its own overhead and complexity. This is not a small problem; it affects almost every serious Rails application.

The memory model is a deeper mismatch. BEAM processes communicate by message passing and expect immutable data. Ruby objects are mutable by default, and a significant amount of Rails’ internal plumbing assumes you can mutate objects in place. ActiveRecord associations, callbacks, the request object itself: these all rely on shared mutable state in ways that are fundamentally incompatible with the BEAM’s process isolation model.

Prior art in this space is instructive. JRuby brought Ruby to the JVM, which solved the GVL problem by giving Ruby true OS-thread parallelism. It works, and it handles the C extension problem by reimplementing the affected libraries in Java. But adoption never reached critical mass; the operational complexity of running the JVM, the cold-start latency, and the partial compatibility story were enough friction that most teams stayed on MRI. TruffleRuby on GraalVM has pushed this further with aggressive JIT compilation and better compatibility, but faces the same adoption dynamics.

A BEAM-targeting Ruby would face all these problems plus the message-passing constraint. It’s a harder project than JRuby.

What a Realistic Path Looks Like

The more achievable near-term version of this idea is probably not running MRI Rails on the BEAM unchanged. It’s closer to what projects like Gleam demonstrate: a language designed from the ground up for the BEAM that borrows ergonomic ideas from other ecosystems without requiring the original runtime.

An Elixir framework that adopts Rails’ conventions more aggressively is perhaps the more tractable direction. Phoenix has routing, plugs, Ecto for database access, and LiveView for reactive UI. It’s missing Rails’ integrated generators, Active Storage’s file handling conventions, and the years of accumulated tooling for things like multi-tenancy and background job observability. But those are solvable problems of time and community investment, not fundamental runtime incompatibilities.

For Rails shops specifically, the Ash Framework on Elixir is worth watching. It applies a declarative, convention-heavy approach to Elixir applications that would feel legible to Rails developers, sitting on top of the BEAM’s concurrency primitives natively.

Where This Leaves Us

Sam Ruby’s framing of Rails on the BEAM is worth taking seriously as a forcing function for thinking about what the Ruby web stack is missing. The BEAM does not have better routing syntax or more expressive query builders than ActiveRecord. What it has is a concurrency primitive that the web’s workload pattern, many parallel connections, many waiting on I/O, was essentially designed for.

Ruby has been getting more capable here incrementally. The Fiber scheduler introduced in Ruby 3.0 enables non-blocking I/O within a single thread, and gems like Async build on it. Pitchfork revived the preforking server model with better memory sharing through CoW. These are meaningful improvements, but they are adaptations to a model, not replacements for one.

The BEAM was built concurrency-first. Whether Rails ever runs on it literally matters less than whether Rails developers absorb the design lessons that made it necessary in the first place.

Was this interesting?