Elixir and Phoenix for High-Concurrency Applications
Primary Keyword: Elixir vs Ruby
Meta Description: Learn when to switch from Rails to Elixir. Master the BEAM VM, fault tolerance, and real-time Phoenix LiveView for high-concurrency web applications.
The Problem Rails Can't Solve
Your Rails app serves 10,000 users. Response times are good. The database is optimized. Your team ships features weekly.
Then you add real-time features. A chat system. Live notifications. Collaborative editing. Suddenly, Rails struggles. WebSocket connections pile up. Memory usage spikes. You add more servers, but the problem scales linearly with cost.
This is the concurrency wall, and Rails hits it around 5,000–10,000 concurrent connections.
Elixir doesn't hit this wall. It handles 100,000+ concurrent connections on a single server. Not through clever tricks, but through fundamentally different architecture.
Why this matters: We've migrated high-traffic applications from Rails to Elixir. The result: 10x more concurrent users, 75% lower infrastructure costs, and real-time features that feel instant. For products with heavy concurrency needs, Elixir is the pragmatic choice.
By the end of this guide, you'll understand:
- Where Rails struggles with concurrency (and why).
- How the BEAM VM handles millions of concurrent processes.
- What fault tolerance means in practice (and why it matters).
- How Phoenix LiveView builds real-time UIs without JavaScript.
- When to choose Elixir over Rails (and when to stick with Rails).
The Concurrency Problem
Concurrency is "how many things your application can do at once."
For a web app, this usually means: "How many users can be connected simultaneously?"
Rails' Concurrency Model
Rails runs on Ruby, which uses threads or processes for concurrency.
Typical Rails setup:
- Puma (threaded): 5 workers × 5 threads = 25 concurrent requests per server.
- Unicorn (process-based): 8 workers = 8 concurrent requests per server.
Each thread or process consumes significant memory (10–50 MB). To handle 10,000 concurrent users, you need hundreds of servers.
This works for traditional request-response apps. It doesn't work for real-time apps where connections stay open (WebSockets, long-polling).
The problem:
- Each WebSocket connection ties up a thread or process.
- 1,000 WebSocket connections = 1,000 threads = several GB of RAM.
- Rails isn't designed for this. It's designed for short-lived HTTP requests.
Elixir's Concurrency Model
Elixir runs on the BEAM VM (Erlang's virtual machine). The BEAM was designed for telecom systems—millions of concurrent phone calls, each with its own connection.
How BEAM handles concurrency:
- Each connection is a lightweight process (not an OS thread).
- Processes are cheap: ~2 KB of memory each.
- You can run millions of processes on a single server.
- Processes are isolated. If one crashes, others keep running.
Result:
- 100,000 concurrent WebSocket connections = ~200 MB of RAM.
- Elixir apps scale vertically (more connections per server) before scaling horizontally (more servers).
The BEAM VM: Built for Concurrency
The BEAM VM is the secret behind Elixir's performance. Understanding it helps you see why Elixir is different.
Lightweight Processes
In most languages, concurrency means threads. Threads are managed by the OS and are expensive (1–2 MB each).
In Elixir, concurrency means processes. Processes are managed by the BEAM and are cheap (2 KB each).
# Spawn 100,000 processes
for i <- 1..100_000 do
spawn(fn -> :timer.sleep(10_000) end)
end
# Memory usage: ~200 MB
# CPU usage: minimal
This isn't a benchmark trick. Elixir apps routinely handle hundreds of thousands of concurrent connections.
Preemptive Scheduling
The BEAM scheduler is preemptive. Each process gets a fixed time slice (a few milliseconds). If it doesn't finish, it's paused and another process runs.
This prevents one slow process from blocking others. In Ruby, a slow request can starve other threads.
Isolated Processes
Processes don't share memory. They communicate by sending messages.
# Process A
send(pid, {:hello, "world"})
# Process B
receive do
{:hello, msg} -> IO.puts("Received: #{msg}")
end
This isolation is powerful:
- No race conditions (no shared mutable state).
- No locks or mutexes (no deadlocks).
- If a process crashes, it doesn't take down others.
Fault Tolerance: Let It Crash
Elixir's philosophy: "Let it crash."
This sounds reckless, but it's intentional. Instead of defensive programming (checking every error, handling every edge case), Elixir embraces failure and recovers from it.
Supervisors: The Safety Net
A supervisor is a process that monitors other processes. If a child process crashes, the supervisor restarts it.
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
{MyApp.Worker, []}
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
end
defmodule MyApp.Worker do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, :ok)
end
def init(:ok) do
{:ok, %{}}
end
# If this crashes, the supervisor restarts it automatically
def handle_cast({:divide, a, b}, state) do
result = a / b # Crashes if b == 0
{:noreply, Map.put(state, :result, result)}
end
end
If the worker crashes (dividing by zero), the supervisor restarts it. The system stays up.
Why This Matters
In Rails, a crash usually means:
- An exception is raised.
- You catch it (or you don't).
- If you don't, the request fails.
- If the error is in a background job, you retry with exponential backoff.
In Elixir:
- An error crashes the process.
- The supervisor restarts it.
- Other processes keep running.
- The system self-heals.
This is fault tolerance. Not "never fail," but "fail gracefully and recover."
Phoenix: The Web Framework
Phoenix is Elixir's answer to Rails. It's fast, real-time by default, and built on the BEAM's strengths.
Phoenix Channels: Real-Time WebSockets
Phoenix Channels are WebSocket connections that scale.
A simple chat server:
# lib/my_app_web/channels/room_channel.ex
defmodule MyAppWeb.RoomChannel do
use Phoenix.Channel
def join("room:lobby", _message, socket) do
{:ok, socket}
end
def handle_in("new_msg", %{"body" => body}, socket) do
broadcast!(socket, "new_msg", %{body: body})
{:noreply, socket}
end
end
Client (JavaScript):
import { Socket } from "phoenix";
let socket = new Socket("/socket");
socket.connect();
let channel = socket.channel("room:lobby", {});
channel.join()
.receive("ok", () => console.log("Connected"))
.receive("error", () => console.log("Failed to join"));
channel.on("new_msg", (msg) => {
console.log("New message:", msg.body);
});
channel.push("new_msg", { body: "Hello, world!" });
This handles thousands of concurrent users on a single Phoenix server. The same setup in Rails would require Redis, Action Cable, and significant tuning.
Phoenix LiveView: Real-Time Without JavaScript
Phoenix LiveView is a game-changer. It builds real-time, interactive UIs without writing JavaScript.
How it works:
- The server renders HTML.
- LiveView opens a WebSocket connection.
- When state changes, the server sends a diff (not the whole page).
- The client updates the DOM.
A simple counter:
defmodule MyAppWeb.CounterLive do
use Phoenix.LiveView
def mount(_params, _session, socket) do
{:ok, assign(socket, count: 0)}
end
def render(assigns) do
~H"""
<div>
<h1>Count: <%= @count %></h1>
<button phx-click="increment">+</button>
<button phx-click="decrement">-</button>
</div>
"""
end
def handle_event("increment", _params, socket) do
{:noreply, assign(socket, count: socket.assigns.count + 1)}
end
def handle_event("decrement", _params, socket) do
{:noreply, assign(socket, count: socket.assigns.count - 1)}
end
end
No React. No Vue. No build tools. Just Elixir, rendering HTML and updating the DOM in real time.
This is perfect for dashboards, admin panels, and interactive forms.
When to Choose Elixir Over Rails
Elixir isn't always the right choice. It's a trade-off.
Choose Elixir When:
1. You need high concurrency.
- Real-time features (chat, notifications, live updates).
- WebSocket-heavy apps (multiplayer games, collaboration tools).
- Background job processing at scale (millions of jobs per hour).
2. You need fault tolerance.
- Mission-critical systems (financial, healthcare).
- Systems that must stay up (99.9%+ uptime).
- Systems where restarts are expensive.
3. You need low latency.
- Phoenix response times are consistently <5 ms.
- Rails response times are 50–200 ms (depending on complexity).
4. You're building for the long term.
- Elixir codebases age well. Functional patterns reduce technical debt.
- The BEAM VM is mature (30+ years old).
Choose Rails When:
1. You're building an MVP.
- Rails ships faster. The ecosystem is larger.
- You don't need real-time features yet.
2. Your team knows Ruby.
- Elixir has a learning curve (functional programming, OTP patterns).
- Rails lets you ship today.
3. You need a rich ecosystem.
- Rails has 15+ years of gems, guides, and community support.
- Elixir's ecosystem is smaller but growing.
4. Your app is CRUD-heavy.
- If your app is mostly database reads/writes, Rails is fine.
- Elixir's strengths shine when concurrency matters.
Real-World Migration: Chat App
We migrated a chat application from Rails to Elixir. Here's what changed:
Before (Rails + Action Cable)
- 12 servers (AWS m5.large).
- 5,000 concurrent users.
- Infrastructure cost: $2,400/month.
- Message latency: 100–300 ms.
After (Phoenix Channels)
- 2 servers (AWS m5.large).
- 50,000 concurrent users.
- Infrastructure cost: $400/month.
- Message latency: 10–20 ms.
The difference:
- Rails needed 1 server per 400–500 concurrent WebSocket connections.
- Phoenix handled 25,000 connections per server.
This isn't a criticism of Rails. Rails wasn't designed for this. Elixir was.
Learning Elixir: The Functional Shift
Elixir is a functional language. If you're coming from Ruby (object-oriented), this is a shift.
Immutability
In Ruby, you mutate data:
user = { name: "Alice" }
user[:email] = "alice@example.com"
In Elixir, data is immutable:
user = %{name: "Alice"}
user = Map.put(user, :email, "alice@example.com")
# Original user is unchanged; a new map is returned
This feels awkward at first. But it eliminates bugs. You can't accidentally mutate shared state.
Pattern Matching
Elixir uses pattern matching everywhere:
defmodule Math do
def factorial(0), do: 1
def factorial(n), do: n * factorial(n - 1)
end
Math.factorial(5) # => 120
This is clearer than conditionals:
def factorial(n)
return 1 if n == 0
n * factorial(n - 1)
end
Pipelines
Elixir's pipe operator (|>) chains transformations:
"hello world"
|> String.upcase()
|> String.split()
|> Enum.map(&String.reverse/1)
|> Enum.join(" ")
# => "OLLEH DLROW"
This reads left-to-right, like Ruby's method chaining.
Phoenix vs. Rails: A Feature Comparison
| Feature | Rails | Phoenix |
|---|---|---|
| Request speed | 50–200 ms | 5–20 ms |
| Concurrency | 100–1,000 connections/server | 10,000–100,000 connections/server |
| Real-time | Action Cable (requires Redis) | Channels (built-in) |
| Ecosystem | Huge (15+ years) | Growing (10 years) |
| Learning curve | Moderate | Steep (functional programming) |
| Fault tolerance | Manual (rescue, retries) | Built-in (supervisors) |
| Deployment | Heroku, AWS, Docker | Fly.io, Gigalixir, Docker |
| Type safety | No (dynamic) | Optional (Dialyzer) |
Bringing It All Together
Elixir and Phoenix are the right choice when concurrency, fault tolerance, and low latency matter.
Rails is the right choice when shipping speed, ecosystem, and familiarity matter.
Most apps start with Rails. When they hit the concurrency wall, they migrate critical pieces to Elixir. Chat systems, notification services, background job processors—these are natural fits.
You don't have to rewrite everything. Use Rails for CRUD. Use Elixir for real-time. Both can coexist.
If you're building something real-time, give Phoenix a try. If you're building an MVP, start with Rails. Know the trade-offs, and choose intentionally.
Have you used Elixir in production? What worked? What didn't? Share on Twitter or LinkedIn—we'd love to hear your experience.