Introduction: The Wall Every Successful Rails App Meets
Ruby on Rails is one of the best tools ever built for going from zero to a real product. That is not in dispute—Shopify, GitHub, and Stripe run enormous businesses on it. But success has a side effect: traffic, data, and concurrency grow, and eventually a Rails app that felt instant at launch starts sweating under load. Latency creeps up, the background queue backs up, and the fix is always the same—add more servers.
This is not a “Rails is bad” article. It is an honest look at where Rails hits real scaling limits, why those limits exist, how far you can push Rails before they bite, and what the options are when scaling horizontally stops being economical. For a specific class of workloads—high concurrency and real-time—the answer increasingly points to Elixir and Phoenix.
Quick answer (TL;DR): Rails scales horizontally—you buy throughput with more processes and more machines. That works well until concurrency, WebSocket density, or real-time features dominate your workload, at which point the cost curve bends the wrong way. The BEAM (Elixir/Phoenix) scales those workloads vertically on a single node, which is where the economics flip.
The Root Cause: Ruby’s Concurrency Model
Almost every Rails scaling limit traces back to one design decision: Ruby’s Global Interpreter Lock (GIL). Even on a many-core machine, a single Ruby process executes one thread of Ruby code at a time. Puma uses threads to overlap I/O waits, which helps for database- and network-bound work, but CPU-bound work in one request can stall the others in that process.
The standard answer is to run multiple processes (Puma workers) and multiple machines behind a load balancer. Each worker is a full copy of your app in memory. So throughput scales—but linearly with RAM and machine count. There is nothing wrong with this until the ratio of servers to users gets expensive.
Where the Limits Actually Show Up
1. WebSocket and Real-Time Density
This is the sharpest wall. Every open WebSocket in a Rails/ActionCable setup consumes a thread slot and leans on Redis for pub/sub across processes. As persistent connections climb into the tens and hundreds of thousands, you scale out aggressively and Redis becomes a bottleneck and a single point of failure. The BEAM, by contrast, was built to hold millions of concurrent connections on a single node—each connection is a lightweight process weighing a couple of kilobytes.
2. Background Job Throughput
Sidekiq is excellent, but at high volume you are running a fleet of worker processes plus Redis, tuning concurrency per queue, and watching for memory bloat. Job latency under burst becomes an operational concern. Elixir handles this in-process with Oban (Postgres-backed) and with lightweight processes for fan-out work—no separate worker fleet and no Redis to babysit.
3. CPU-Bound Request Work
Any request that does meaningful in-process computation— serialization of large payloads, in-memory aggregation, image or data processing—contends for the GIL. YJIT (Ruby 3+/4) has massively improved raw execution speed and closed much of the single-threaded gap, but it does not change the one-thread-at-a-time concurrency model. The BEAM runs these across all cores with preemptive scheduling so no single task can hog a scheduler.
4. Tail Latency Under Load
Rails average latency can look fine while p99 latency degrades badly under load, because a few slow requests block worker slots. The BEAM’s preemptive scheduler is specifically designed to keep tail latency flat—it will not let one expensive operation starve thousands of cheap ones.
How Far Can You Push Rails First?
Before rewriting anything, exhaust the Rails scaling toolkit—most apps never actually need to leave. In order of leverage:
- Fix the database first. The overwhelming majority of “Rails is slow” problems are N+1 queries, missing indexes, and unbounded result sets—not the runtime. Profile before you scale.
- Cache aggressively. Fragment caching, HTTP caching, and Rails 8’s Solid Cache remove entire categories of load.
- Tune Puma. Right-size worker and thread counts to your actual CPU and memory; misconfiguration is common.
- Move heavy work out of the request cycle. Push anything slow into background jobs.
- Scale horizontally. Add app servers behind a load balancer. This is Rails’ native scaling story and it goes a long way.
If you have done all of this and the cost of the next order of magnitude is still dominated by concurrency or real-time load, that is the real signal—not a slow endpoint you never profiled.
When Phoenix Is the Right Escape Hatch
Phoenix runs on the same BEAM that was built at Ericsson to keep telecom switches online through millions of concurrent connections and hardware faults. That heritage is exactly what a scaling-limited Rails app needs: native multi-core concurrency, cheap connections, flat tail latency, and self-healing supervision trees. For the mechanics of why—processes, schedulers, and supervisors—see What Is the BEAM and OTP.
The move does not have to be all-or-nothing. The pragmatic path is to keep Rails for the CRUD it does well and migrate the concurrency-heavy surfaces to Phoenix incrementally—both apps sharing one database—as described in our Rails to Phoenix migration guide. And if you are weighing the two frameworks for a greenfield decision, the full Rails vs Phoenix comparison breaks down the trade-offs.
Frequently Asked Questions
At what scale does Ruby on Rails start to struggle?
There is no single number—it depends on workload shape, not user count. CRUD-heavy apps scale to very large traffic on Rails by adding servers and caching. The limits show up sooner for workloads dominated by persistent WebSocket connections, high background-job throughput, or CPU-bound in-process work, because those press against Ruby’s single-threaded-per-process concurrency model.
Does YJIT fix Rails’ scaling problems?
YJIT significantly improves Ruby’s raw execution speed and is worth enabling, but it does not change the concurrency model. One Ruby thread still executes at a time per process because of the Global Interpreter Lock, so YJIT helps per-request speed without removing the need to scale out for high concurrency.
Should I rewrite my Rails app in Phoenix to scale?
Rarely a full rewrite, and never as a first step. Exhaust database tuning, caching, and horizontal scaling first—most apps never need to leave Rails. If concurrency or real-time load still dominates your cost curve, migrate the affected surfaces to Phoenix incrementally while Rails keeps handling the rest.
Can Phoenix handle more concurrent connections than Rails?
Dramatically more, on a single node. The BEAM holds each connection in a lightweight process of a few kilobytes and schedules work across all CPU cores, so one Phoenix node can maintain hundreds of thousands to millions of concurrent connections. Rails scales connections by adding processes and machines, which costs far more per connection at that density.
Let Equantra Diagnose Your Scaling Wall
Not every scaling problem needs a new framework—and knowing the difference is worth a lot. At Equantra we work across both Ruby on Rails and Elixir Phoenix, so we can tell you honestly whether your bottleneck is a query, a config, or a genuine runtime limit that Phoenix would solve.
Our team brings:
- Deep experience profiling and scaling production Rails applications
- Production Elixir Phoenix expertise for the workloads Rails cannot scale economically
- Incremental migration experience—no big-bang rewrites, no frozen roadmaps
- A dedicated team model with committed engineers, not a rotating pool of contractors
Hire Elixir developers to break through your scaling wall, or get a free consultation to diagnose where your Rails app is actually bottlenecked.