Ruby on Rails vs Elixir Phoenix
The question behind "Rails or Phoenix" is almost never which framework is nicer to write. It is whether the runtime is the thing standing between the application and the traffic it has, and that is an empirical question with an answer that changes depending on what the endpoint does. Two of the three endpoints measured below say Phoenix wins by an order of magnitude. One of them says the two frameworks are within 5% of each other, and that one is the shape most web requests actually have.
Everything here was run on 2026-09-27 on an Apple M2 Max with 12 cores, macOS arm64-darwin25, both applications in production mode against PostgreSQL 17.7 on port 15432.
The two applications every number came from
Two scaffolds, generated within a minute of each other into sibling directories, holding the same table and the same rows:
mix phx.new blog_ex --app blog_ex --no-mailer --install
rails new blog_rb --database=postgresql --skip-git --skip-ci --skip-kamal --skip-docker
Versions, printed rather than assumed: Ruby 4.0.5, Rails 8.1.4, Puma 8.0.2. Elixir 1.20.4 compiled
with Erlang/OTP 29, Phoenix 1.8.15, Bandit 1.12.5, Ecto 3.14.2, Postgrex 0.22.4. The Rails app was
generated with --skip-kamal --skip-docker, which is why its 138 resolved gems are not 150.
Both got the same three extra endpoints. /bench/db selects 100 titles ordered by id and joins them
with commas. /bench/sleep waits 200 ms and returns the string slept. /bench/cpu computes a
naive recursive fib(27) and returns it. The responses are byte-identical across the two servers:
diff <(curl -s :3971/bench/db) <(curl -s :4971/bench/db) is empty, and both /bench/cpu return
196418.
class BenchController < ApplicationController
def sleeper
sleep 0.2
render plain: "slept"
end
def cpu
render plain: BenchController.fib(27).to_s
end
def db
render plain: Post.order(:id).limit(100).pluck(:title).join(",")
end
def self.fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2)
end
defmodule BlogExWeb.BenchController do
use BlogExWeb, :controller
import Ecto.Query
def sleeper(conn, _params) do
Process.sleep(200)
text(conn, "slept")
end
def cpu(conn, _params) do
text(conn, Integer.to_string(fib(27)))
end
def db(conn, _params) do
titles =
BlogEx.Repo.all(
from p in BlogEx.Blog.Post, order_by: p.id, limit: 100, select: p.title
)
text(conn, Enum.join(titles, ","))
end
defp fib(n) when n < 2, do: n
defp fib(n), do: fib(n - 1) + fib(n - 2)
end
The load generator is /usr/sbin/ab, no keep-alive, running on the same laptop as both servers, so
it is competing for the same 12 cores. That inflates nothing in one direction: it applies to both.
Three endpoints, and they do not agree about who wins
Each row is 200 warm-up requests followed by the run shown, against a server that had already served at least 2000 requests:
| endpoint | Rails, stock (1 process, 3 threads) | Rails, WEB_CONCURRENCY=12 RAILS_MAX_THREADS=5 |
Phoenix, stock |
|---|---|---|---|
/bench/db, n=2000 c=12 |
1431.54 req/s, p50 8 ms, p99 17 ms | 4574.46 req/s, p50 2 ms, p99 7 ms | 15775.11 req/s, p50 1 ms, p99 2 ms |
/bench/sleep, n=150 c=50 |
14.15 req/s, p50 3338 ms, p99 3538 ms | 176.36 req/s, p50 209 ms, p99 221 ms | 184.32 req/s, p50 203 ms, p99 206 ms |
/bench/cpu, n=120 c=12 |
68.71 req/s, p50 174 ms, p99 177 ms | 526.61 req/s, p50 19 ms, p99 44 ms | 3615.76 req/s, p50 3 ms, p99 8 ms |
Zero failed requests in all nine runs.
The same two servers at -c 1, which is the number nobody quotes because it is the boring one:
1335.35 requests per second on Rails against 2671.05 on Phoenix for /bench/db, 0.749 ms against
0.374 ms. Exactly 2x. Whatever produces the 11x in the table above, it is not the cost of handling
one request.
Fourteen requests a second is threads 3, 3 and nothing else
The /bench/sleep row for stock Rails is the one worth staring at, because 14.15 requests per
second looks like a catastrophe and is arithmetic. Rails 8.1 generates this in config/puma.rb:
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count
Three threads, each blocked for 0.2 s, is 15 requests per second. The measurement was 14.15. The 50
connections ab opened queue behind those three, which is why p50 landed at 3338 ms: the median
request spent over three seconds in the accept queue before any Ruby ran for it. Nothing in the
application is slow. The ceiling is the pool.
Ruby's sleep releases the GVL, so this is not a GVL result. It is the honest model of an endpoint
that waits on something else: a payment provider, a search cluster, an LLM. Any Rails action that
spends most of its wall time waiting occupies one of three slots while it waits.
The fix is one environment variable, and the surprise is that config/puma.rb does not mention it.
There is no workers line anywhere in the generated file. WEB_CONCURRENCY=12 RAILS_MAX_THREADS=5
still produces Puma starting in cluster mode... * Workers: 12, because Puma reads the variable
itself, in puma_options_from_env at puma-8.0.2/lib/puma/configuration.rb:248:
workers_env = env['WEB_CONCURRENCY']
workers = workers_env && workers_env.strip != "" ? parse_workers(workers_env.strip) : nil
With that set, the blocking endpoint goes from 14.15 to 176.36 requests per second and p50 from 3338 ms to 209 ms, against Phoenix's 184.32 and 203 ms. On the request shape that describes most of a typical SaaS, a correctly configured Rails app and a stock Phoenix app are 4% apart. Anyone quoting a 10x concurrency advantage from a default Puma configuration is measuring the configuration.
The GVL is the line Rails cannot configure past
/bench/cpu is where more workers stop rescuing the comparison. Stock Rails did 68.71 requests per
second at -c 12. Twelve workers took it to 526.61, which is 7.7x for 12 processes. Phoenix did
3615.76 with no configuration at all.
Two separate effects stack there, and they are worth separating because only one of them is about
concurrency. At -c 1, one fib(27) took 14.570 ms in CRuby and 1.772 ms on the BEAM, an 8.2x gap
on a single request with no parallelism involved. That is the JIT and the calling convention, not
the scheduler. Then the scheduler multiplies it: one Ruby process can only ever burn one core on
Ruby code, so -c 12 against a single Puma process is still one core of work, while the BEAM's 12
schedulers spread the same load across all 12.
The practical reading is narrow. Almost no Rails action is CPU bound, and the ones that are should not be in the request cycle anyway. If yours are, the honest options are the same ones as ever: move the work to a job, move it to the database, or move it out of Ruby. Phoenix does not make this problem go away either so much as raise the ceiling by an order of magnitude before you have to care.
Twelve workers cost 13 resident sets and 28 database connections
The clustered configuration that closed the concurrency gap is not free, and the price shows up in
two places an operator pays for. After 1500 requests at -c 12, ps reported the Puma master at
113 MB resident and its twelve workers at 99 to 108 MB each, 13 processes summing to 1360 MB. The
single beam.smp serving the identical load was at 60 MB.
Summing resident sets overstates the real figure, because forked Puma workers share pages with the master until they write to them. The comparison that survives that caveat is structural rather than arithmetic: twelve Ruby heaps against one BEAM heap, and twelve copies of the application's loaded classes against one copy of its loaded modules.
The connections are harder to argue with, because pg_stat_activity counts them:
datname | backends
---------------------+----------
blog_ex_dev | 10
blog_rb_development | 28
Twelve workers at five threads is a ceiling of 60 backends from one machine; 28 were open at the
moment of the count. Phoenix held 10, the default pool_size in config/runtime.exs, and would
hold 10 from that node no matter how many requests arrived, because request processes queue for the
pool instead of owning a connection. On a managed Postgres with a connection limit, that difference
is the one that decides whether you are shopping for PgBouncer.
The counter that explains why a Rails deployment needs Redis
A counter kept in the application's own memory is the smallest possible test of whether a
deployment has one shared state or N unrelated ones. Both applications got one. Rails used a
Concurrent::AtomicFixnum held in a constant; Phoenix used an ETS table created in
BlogEx.Application.start/2:
COUNTER = Concurrent::AtomicFixnum.new(0) if defined?(Concurrent)
def counter
render plain: COUNTER.increment.to_s
end
def counter(conn, _params) do
n = :ets.update_counter(:bench_counter, :hits, 1, {:hits, 0})
text(conn, Integer.to_string(n))
end
Both servers were restarted to zero the counters, then hit with 300 requests from a Python
ThreadPoolExecutor of 8 workers, each response parsed as an integer:
RAILS 12 Puma workers x 5 threads
300 requests, distinct values returned: 35
highest value any request saw: 35
value returned most often: 1 (seen 12 times)
sorted set of values == 1..300? False
PHOENIX 1 BEAM node
300 requests, distinct values returned: 300
highest value any request saw: 300
value returned most often: 5 (seen 1 times)
sorted set of values == 1..300? True
The value 1 came back twelve times because there are twelve workers and each one started its own
counter at zero. Concurrent::AtomicFixnum is doing its job perfectly inside each process and is
irrelevant across them. This is not a Ruby limitation and it is not fixable with a better mutex: the
workers are separate OS processes with separate address spaces, which is the same property that
makes WEB_CONCURRENCY=12 work at all.
Every piece of shared state a Rails application needs therefore has to live somewhere outside the process, which is why the rate limiter, the cache, the feature flag and the WebSocket registry all end up in the same place. Rails 8 answers that with the database instead of Redis, which is a real improvement and is still a network hop and a table. A Phoenix node answers it with a term in memory that every request on that node can read in microseconds, and only needs a distributed answer when there is a second node. If your application is one box and mostly reads its own state, that gap is larger than any number in the benchmark table.
Ecto will not let you write the N+1 by accident
The other structural difference shows up on the first association. Both apps got a Comment that
belongs to a Post, ten posts with three comments each, and a spec counting queries.
Active Record issues one query per post and says nothing:
Post Load (0.5ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC
Comment Load (0.5ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = $1 [["post_id", 31]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = $1 [["post_id", 32]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = $1 [["post_id", 33]]
Ecto does not lazy load at all. A schema loaded without preload: carries a sentinel struct where
the association would be, and the sentinel does not implement Enumerable:
post.comments => #Ecto.Association.NotLoaded<association :comments is not loaded>
RAISED: protocol Enumerable not implemented for Ecto.Association.NotLoaded (a struct).
That is the whole difference and it is a design decision rather than a performance one. Ecto trades
the convenience of post.comments working anywhere for the guarantee that a query never happens
where you did not write one, which means the N+1 cannot reach production because it cannot reach the
end of the request. Active Record trades the other way, and the cost is a class of bug this site has
a whole article on finding. strict_loading exists and is
opt-in, which is not the same thing as a runtime that has no lazy load to turn off.
Both claims are asserted rather than described. On the Rails side, test/models/n_plus_one_test.rb
counts sql.active_record notifications and asserts 11 queries without includes and 2 with it. On
the Elixir side, test/blog_ex/n_plus_one_test.exs attaches to [:blog_ex, :repo, :query] telemetry
and asserts 1 query with no preload, 2 with it, and Protocol.UndefinedError on the unloaded
association. bin/rails test reports 10 runs, 15 assertions, 0 failures, 0 errors, 0 skips;
mix test reports 24 tests, 0 failures.
What the two generators actually write
rails g scaffold Post title:string body:text produced 240 lines across 16 files, counted with
wc -l over exactly the paths the generator printed. mix phx.gen.html Blog Post posts
title:string body:text produced 449 across 13. The difference is almost entirely
lib/blog_ex/blog.ex, the context module: 104 lines of CRUD functions and @doc blocks that Rails
does not generate because Post.create! is already the API.
Phoenix also does not wire the route. The generator ends with Add the resource to your browser
scope in lib/blog_ex_web/router.ex: and leaves it to you, where rails g scaffold injects
resources :posts into config/routes.rb. One is a five second edit and the other is a philosophy,
and which one you prefer is a reasonable proxy for which framework you will enjoy.
The dead end: mix assets.deploy on a clean build
A first production build of the untouched generated Phoenix app fails. With MIX_ENV=prod and no
_build/prod directory, mix assets.deploy stops at the Tailwind step:
≈ tailwindcss v4.3.3
Error:
┌
│ Error: Can't resolve 'phoenix-colocated/blog_ex/colocated.css' in '/.../blog_ex/assets/css'
└
==> blog_ex
** (Mix) `mix tailwind blog_ex --minify` exited with 1
The phoenix-colocated directory is written by the Elixir compiler out of the colocated hooks in
core_components.ex, so it does not exist until a compile has happened, and assets.deploy does
not compile first. Running mix compile and then mix assets.deploy works, and the failure
reproduces every time _build/prod is wiped. It is a 30 second problem once you know it and an
opaque one the first time, which is roughly the shape of most of the Elixir tooling friction: the
error message is precise and tells you nothing about what to do.
What you give up, priced in one dependency
The ecosystem argument is usually made with adjectives. Here is one number instead, for the single
dependency a paid application cannot avoid. On rubygems.org the stripe gem is at version 19.6.2
with 122,743,520 downloads and its source is github.com/stripe/stripe-ruby, which is Stripe's own
organisation. On hex.pm the equivalent is stripity_stripe 3.3.2 with 6,285,499 downloads, from
github.com/code-corps/stripity_stripe, which is not. Both read from the two registries' public
APIs on 2026-09-27.
That is the trade in miniature, and it repeats for the admin panel, the file attachment library, the PDF generator and the fourth-party API wrapper you will need in month two. Phoenix gives you a better runtime and a smaller shelf. Whether that is a good deal depends entirely on whether the runtime was ever your problem.
The call, and what would change it
On this evidence, a new SaaS should be written in Rails, and the reason is the /bench/sleep row:
with one environment variable set correctly, the framework that is supposedly 10x behind is 4%
behind on the request shape that dominates a web application. The order-of-magnitude differences
live on CPU-bound work that should not be in a controller and on per-request speed that is 2x, not
10x, once nobody is measuring a default thread pool.
Three findings would change that answer. If the application holds a lot of in-memory state per node, the counter section is not a curiosity but the architecture, and the Redis or Solid Cache hop Rails needs is a permanent tax Phoenix does not pay. If it runs tens of thousands of long-lived connections, twelve OS processes at 100 MB each is the wrong shape and one BEAM node is the right one. And if it really is CPU bound in the request, 68.71 against 3615.76 is not a gap any amount of Puma tuning closes.
The thing that would not change it is a benchmark table. Two of the three rows above favour Phoenix by more than 10x and neither of them describes the work a typical application does.
What this post does not cover
LiveView against Hotwire, which is the comparison most people actually mean when they ask about
Phoenix and is a different article, needing a stateful page under measurement rather than three
endpoints. Nothing here touches distribution: a single BEAM node was measured, and the clustering
that makes :ets into :pg across machines was never started. Deployment is absent, so mix
release against a Docker image against Kamal is unmeasured, and the
Phoenix boot times quoted anywhere else in this batch would be wrong here because mix phx.server
was used rather than a compiled release.
The hiring question is deliberately left alone. It has an obvious answer and this site has no primary source for it beyond the salary figures already collected in the jobs cluster, so anything more would be a claim about a market rather than a measurement.
No Rails 8 Solid Queue, Solid Cache or Action Cable feature was benchmarked against its OTP equivalent. The comparison this post makes is about the runtime underneath both frameworks, and the case for running Rails with no Redis at all is a separate argument that the counter section touches and does not settle.
Comments
No comments yet. Be the first.