When to Choose Phoenix Over Rails
Ruby on Rails has been the darling of web startups for nearly two decades. Its convention-over-configuration philosophy, rich ecosystem, and developer happiness made it the go-to framework for shipping products quickly. But as applications scale and real-time features become the norm rather than the exception, many teams find themselves hitting walls with Rails. Enter Phoenix, a web framework written in Elixir that runs on the Erlang VM. Phoenix borrows heavily from Rails in terms of developer experience while offering fundamentally different runtime characteristics. This tutorial explores when Phoenix is the right choice over Rails and how to think about the trade-offs.
What Is Phoenix?
Phoenix is a web framework for Elixir, a functional language that compiles to bytecode running on the BEAM virtual machine — the same VM that powers Erlang. The BEAM was built at Ericsson in the 1990s for telecom switches that needed to handle millions of concurrent connections with near-zero downtime. Phoenix leverages this foundation to deliver a framework that feels familiar to Rails developers but behaves very differently under load.
Like Rails, Phoenix follows MVC conventions, ships with a router, controllers, views, and a database layer called Ecto. Unlike Rails, Phoenix processes requests using lightweight processes (not OS threads), supports hot code reloading in production, and includes LiveView — a feature that lets you build real-time, interactive UIs without writing JavaScript.
Why the Choice Matters
Framework selection is one of the most consequential architectural decisions a team makes. Switching frameworks mid-project is painful and expensive, so getting it right early pays dividends. The Rails-versus-Phoenix decision is not about which framework is objectively better — both are excellent. It is about matching the framework's strengths to your application's demands and your team's constraints.
Rails excels at rapid prototyping, content-heavy CRUD applications, and teams that prioritize time-to-market over raw performance. Phoenix excels at real-time features, high concurrency, fault tolerance, and systems that need to maintain many simultaneous stateful connections. Understanding where each framework shines helps you avoid costly migrations later.
Key Scenarios Where Phoenix Wins
1. Real-Time and Stateful Connections
If your application involves chat, live dashboards, collaborative editing, notifications, or multiplayer experiences, Phoenix is purpose-built for this world. Phoenix Channels and LiveView use WebSockets to maintain persistent connections, and the BEAM's process model means each connection costs a few kilobytes rather than a megabyte-sized thread.
Consider a LiveView example that updates a counter in real time across all connected clients:
defmodule MyAppWeb.CounterLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, count: 0)}
end
def render(assigns) do
~L"""
<div>
<h1>Count: <%= @count %></h1>
<button phx-click="increment">+</button>
</div>
"""
end
def handle_event("increment", _params, socket) do
{:noreply, update(socket, :count, &(&1 + 1))}
end
end
Notice there is no JavaScript here. The phx-click attribute sends an event over the WebSocket, the server processes it, and the diff is pushed back to the client. In Rails, achieving this would require ActionCable plus a JavaScript frontend framework, doubling your stack complexity.
2. High Concurrency Requirements
Rails runs on a model where each request occupies a worker thread or process. With Puma and multithreading, a typical Rails server might handle a few hundred concurrent requests per box. Phoenix, running on the BEAM, can handle hundreds of thousands of concurrent connections on a single machine because each connection is a cheap Erlang process scheduled across all CPU cores.
Here is a simple Phoenix controller that fans out concurrent work using Elixir's Task module:
defmodule MyAppWeb.ReportController do
use MyAppWeb, :controller
def index(conn, _params) do
tasks = [
Task.async(fn -> fetch_sales_data() end),
Task.async(fn -> fetch_inventory_data() end),
Task.async(fn -> fetch_customer_data() end)
]
results = Task.await_many(tasks, 5000)
json(conn, %{
sales: Enum.at(results, 0),
inventory: Enum.at(results, 1),
customers: Enum.at(results, 2)
})
end
defp fetch_sales_data, do: MyApp.Repo.all(MyApp.Sales.Report)
defp fetch_inventory_data, do: MyApp.Repo.all(MyApp.Inventory.Item)
defp fetch_customer_data, do: MyApp.Repo.all(MyApp.Customer)
end
In Rails, you would need to reach for background job libraries like Sidekiq or use raw threads with all their synchronization pitfalls. In Elixir, concurrent execution is a first-class language feature.
3. Fault Tolerance and Uptime
The BEAM uses a "let it crash" philosophy. Processes are supervised and automatically restarted when they fail. This means a bug in one request handler does not bring down the whole server. In Rails, an unhandled exception in a worker can leak state, and memory leaks in long-running processes are a well-known pain point.
Phoenix applications define supervision trees explicitly:
defmodule MyApp.Application do
use Application
@impl true
def start(_type, _args) do
children = [
MyApp.Repo,
MyAppWeb.Endpoint,
{Phoenix.PubSub, name: MyApp.PubSub},
{MyApp.Cache, name: MyApp.Cache}
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
end
If MyApp.Cache crashes, the supervisor restarts it without affecting the database connection pool or the web endpoint. This isolation is baked into the runtime.
4. Memory Efficiency Under Load
Because Elixir processes are lightweight and garbage collected individually, Phoenix applications tend to have flat memory profiles even under heavy traffic. Rails applications, running on the Ruby MRI garbage collector, often exhibit memory growth that requires careful tuning and periodic restarts. For applications that need to run for weeks or months without restarts, Phoenix is the safer bet.
When Rails Is Still the Better Choice
Phoenix is not a silver bullet. There are clear situations where Rails remains the superior choice:
- Team expertise: If your team knows Ruby deeply and has no Elixir experience, the learning curve of functional programming and the BEAM ecosystem may not be worth it for a simple CRUD app.
- Content-heavy sites: Blogs, marketing sites, e-commerce catalogs, and admin panels benefit from Rails' mature ecosystem of gems like Devise, ActiveAdmin, and Solidus.
- Fast prototyping: Rails' scaffolding and ActiveRecord migrations let you ship an MVP in days. Phoenix is fast to develop in once you know it, but the initial ramp-up is steeper.
- Rich library ecosystem: Ruby has gems for almost everything. Elixir's Hex ecosystem is growing but still smaller in niche areas like PDF generation, payment processing, and legacy API integrations.
- Background processing maturity: Sidekiq is battle-tested and feature-rich. While Oban for Elixir is excellent, Sidekiq's web UI and ecosystem are more mature.
How to Evaluate the Decision for Your Project
Assess Your Real-Time Needs
List the features your application requires. If more than 30% of your feature set involves real-time updates, live notifications, or collaborative state, Phoenix's LiveView will save you significant frontend complexity. If your app is mostly request-response with occasional WebSockets, Rails with ActionCable may suffice.
Estimate Connection Volume
Calculate your expected concurrent connections. If you expect fewer than a few thousand simultaneous users, Rails handles this fine. If you anticipate tens of thousands or more — for example, a live event streaming platform or an IoT dashboard — Phoenix's concurrency model becomes essential.
Consider Operational Complexity
Phoenix deployments are straightforward: a single BEAM release can run as a systemd service with no external process manager. Rails typically requires Puma workers, Sidekiq processes, and careful memory tuning. However, Phoenix observability tooling, while excellent with Telemetry, requires more setup than Rails' plug-and-play gems like New Relic or Scout.
Best Practices When Adopting Phoenix
Start with Ecto, Not ActiveRecord Assumptions
Ecto, Phoenix's database layer, is not an ORM. It is a data mapping and query DSL. Embracing explicit queries rather than expecting magic associations will save you frustration:
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :name, :string
field :email, :string
has_many :posts, MyApp.Post
timestamps()
end
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email])
|> validate_required([:name, :email])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/)
|> unique_constraint(:email)
end
end
# Explicit query rather than implicit association loading
defmodule MyApp.UserQueries do
import Ecto.Query
def with_posts(user_id) do
MyApp.User
|> where(id: ^user_id)
|> preload(:posts)
|> MyApp.Repo.one()
end
end
The preload function makes N+1 queries explicit. There is no lazy loading by default, which prevents the performance footguns that plague Rails applications.
Embrace Pattern Matching
Elixir uses pattern matching extensively instead of conditional logic. This leads to clearer, more maintainable code:
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
def show(conn, %{"id" => id}) do
case MyApp.Accounts.get_user(id) do
nil -> conn |> put_status(:not_found) |> json(%{error: "not found"})
user -> conn |> json(user)
end
end
# Pattern match on different event types in a LiveView
def handle_event("save_draft", %{"content" => content}, socket) do
MyApp.Drafts.save(socket.assigns.current_user, content)
{:noreply, put_flash(socket, :info, "Draft saved")}
end
def handle_event("publish", %{"content" => content}, socket) do
case MyApp.Posts.publish(socket.assigns.current_user, content) do
{:ok, post} ->
{:noreply, push_redirect(socket, to: ~p"/posts/#{post}")}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
end
Use Supervisors for Long-Running Work
Any stateful or long-running process should be supervised. Do not spawn processes ad hoc without supervision:
defmodule MyApp.Cache do
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, %{}, opts)
end
def get(name) do
GenServer.call(via_tuple(name), :get)
end
def put(name, value) do
GenServer.call(via_tuple(name), {:put, value})
end
defp via_tuple(name) do
{:via, Registry, {MyApp.CacheRegistry, name}}
end
@impl true
def init(state), do: {:ok, state}
@impl true
def handle_call(:get, _from, state) do
{:reply, state, state}
end
@impl true
def handle_call({:put, value}, _from, _state) do
{:reply, :ok, value}
end
end
Leverage LiveView for Interactive UIs
LiveView is Phoenix's killer feature. Use it for forms with live validation, search-as-you-type, dashboards, and any UI that would otherwise require a JavaScript SPA. But avoid using LiveView for static content — it adds unnecessary WebSocket overhead for pages that do not need interactivity.
defmodule MyAppWeb.SearchLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, query: "", results: [])}
end
def render(assigns) do
~L"""
<div>
<input type="text" phx-keyup="search" value="<%= @query %>" placeholder="Search..." />
<ul>
<%= for result <- @results do %>
<li><%= result.name %></li>
<% end %>
</ul>
</div>
"""
end
def handle_event("search", %{"value" => query}, socket) when byte_size(query) >= 2 do
results = MyApp.Search.search(query)
{:noreply, assign(socket, query: query, results: results)}
end
def handle_event("search", %{"value" => query}, socket) do
{:noreply, assign(socket, query: query, results: [])}
end
end
Monitor with Telemetry
Phoenix ships with Telemetry for observability. Attach handlers to track request latency, database query times, and channel events:
defmodule MyApp.Telemetry do
use Supervisor
import Telemetry.Metrics
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(_arg) do
children = [
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
counter("phoenix.endpoint.start.duration"),
summary("phoenix.endpoint.stop.duration",
unit: {:native, :millisecond}
),
summary("my_app.repo.query.total_time",
unit: {:native, :millisecond}
),
last_value("vm.memory.total", unit: :byte)
]
end
defp periodic_measurements do
[
{__MODULE__, :measure_users, []}
]
end
def measure_users do
:telemetry.execute([:my_app, :users], total: MyApp.Repo.aggregate(MyApp.User, :count))
end
end
Migration Strategy: Moving from Rails to Phoenix
If you decide to move an existing Rails application to Phoenix, do not attempt a big-bang rewrite. Instead, use a strangler-fig approach:
- Phase 1: Build new features in Phoenix while keeping the Rails app as the primary frontend. Share the database between both applications.
- Phase 2: Move real-time features first — these benefit most from Phoenix's architecture. Use Phoenix as a subdomain or API service.
- Phase 3: Gradually migrate CRUD endpoints, starting with the most performance-sensitive ones.
- Phase 4: Decommission Rails once all traffic routes through Phoenix.
Both frameworks can share a PostgreSQL database cleanly. Ecto can read Rails' created_at and updated_at columns with minor configuration:
defmodule MyApp.Repo.Migrations.UseRailsTimestamps do
use Ecto.Migration
def up do
# Ecto uses inserted_at by default; alias to Rails' created_at
execute "ALTER TABLE users RENAME COLUMN created_at TO inserted_at;"
end
end
# Or configure timestamps globally in your schema
defmodule MyApp.User do
use Ecto.Schema
@timestamps_opts [type: :utc_datetime, inserted_at: :created_at]
schema "users" do
field :name, :string
timestamps()
end
end
Conclusion
Choosing between Phoenix and Rails is ultimately a question of what your application demands. If you are building a content-driven site with a small team that values shipping speed and a mature gem ecosystem, Rails remains an outstanding choice. If your application needs real-time interactivity, massive concurrency, fault tolerance, or long-running uptime without memory bloat, Phoenix and the BEAM provide capabilities that Rails simply cannot match. The best approach is to honestly assess your requirements — connection volume, real-time feature density, team skills, and operational constraints — before committing. Both frameworks reward deep investment, and neither is a mistake when chosen for the right reasons. The mistake is choosing based on hype rather than the specific demands of the system you are building.