Phoenix LiveView vs Rails Hotwire: I Built the Same Real-Time App in Both. The Numbers Aren't Close
TL;DR — Phoenix LiveView delivers ~5× more HTTP throughput and establishes WebSocket connections ~14× faster under load. Rails is not broken — it's a mature, productive framework — but for real-time collaborative applications, the Erlang VM is a different league.
Why I Did This
Every time the Phoenix vs Rails debate comes up online, the arguments are the same: "Phoenix is fast but Elixir has a small ecosystem." "Rails is slow but you can hire Rails developers." Nobody shows numbers from a fair, controlled experiment.
So I built the exact same app twice — a collaborative real-time todo board — once in Phoenix LiveView and once in Rails 8 + Hotwire. Same features. Same HTML structure. Same Tailwind classes. Different everything underneath. Then I hammered both with k6 and a Node.js WebSocket flood and let the data speak.
The source code and CI results are open on GitHub.
What I Built
A collaborative todo board where multiple users share a room, see each other's presence as colored avatars, and watch todos appear, toggle, and disappear in real time — without any page refreshes. Think a stripped-down Trello card.
Features:
- Live presence — colored avatar per connected user, updated instantly
- Typing indicator — "[Name] is typing…" shown to all other clients
- Real-time CRUD — add, toggle, edit, delete — broadcast to all connected clients
Same URL scheme on both: /room/:id drops you into a shared room.
The Architecture Gap
Before the numbers, you need to understand what's different under the hood. This is the part that explains the numbers.
Phoenix: One socket, one process, zero JS
Browser
└─ 1 WebSocket
├─ LiveView process (stateful, in Erlang VM)
│ ├─ Phoenix.Presence (CRDT, built-in)
│ ├─ PubSub subscription
│ └─ handle_event (add/toggle/delete todo)
└─ Erlang PubSub ── broadcasts to all LiveView processes
Every browser tab gets one WebSocket that carries everything: DOM patches, presence diffs, typing events. The LiveView process is a long-lived Erlang process (~2KB RAM) that holds the full room state in memory. When a user adds a todo, it goes over the existing WebSocket, the process saves it to Postgres, broadcasts to the room, and sends back a minimal DOM diff — all within the same process. Zero lines of application JavaScript were written for any of this.
Rails: Two sockets, HTTP round-trips, ~200 lines of JS
Browser
├─ WS #1: Turbo::StreamsChannel (HTML diffs for todos)
└─ WS #2: RoomChannel (presence + typing JSON)
HTTP POST/PATCH/DELETE
└─ TodosController → saves to DB → broadcasts Turbo Stream
Rails Hotwire splits the work across two Action Cable connections per tab. CRUD goes over HTTP (POST/PATCH/DELETE), the controller saves to the DB, then broadcasts a Turbo Stream to update other clients' DOMs. Presence and typing go over a second, separate WebSocket. Three Stimulus controllers (~200 lines of JS) wire everything together on the client.
Neither approach is wrong. Rails' model is straightforward and maps cleanly to how the web works. Phoenix's model is more novel but more powerful. The question is: at what cost?
The Benchmark Setup
-
Hardware: GitHub Actions
ubuntu-latest(2 vCPU, 7GB RAM) — identical for both - Database: PostgreSQL 16 (service container)
- Phoenix: dev mode, default config
- Rails: dev mode, tuned: 2 Puma workers × 5 threads = 10 concurrent handlers, PostgreSQL Action Cable adapter
Rails was given every reasonable advantage for a fair fight: multiple Puma workers, threads tuned to the core count, and a proper cross-process cable adapter.
Three benchmark tools were run sequentially so they never competed for CPU:
- k6 HTTP — ramps from 10 → 100 virtual users, each loading the room page then creating a todo
- k6 WebSocket — ramps from 50 → 500 VUs, each connecting and subscribing to a room
- ws_flood — opens 500 persistent WebSocket connections and holds them for 20 seconds
The Numbers
All three benchmark tools ran sequentially on the same CI runner so they never competed for CPU. Here's what came back.
HTTP Throughput
| Metric | Phoenix LiveView | Rails Hotwire |
|---|---|---|
| Request p50 (median) | 130 ms | 1,484 ms |
| Request p95 | 1,051 ms | 8,131 ms |
| Request p99 | 1,397 ms | 8,708 ms |
| Todo create p95 | 888 ms | 8,138 ms |
| Todos created in 70s | ~3,200 | ~640 |
| Error rate | 0% | 0% |
Phoenix handled ~5× more requests and responded at the median ~11× faster.
The reason is architectural, not a tuning problem. Rails handles requests with OS threads (bounded by workers × threads). The Erlang VM schedules millions of lightweight processes across all CPU cores, preemptively time-slices them, and never blocks. When 100 users hit simultaneously, Erlang fans them out across all available schedulers. Puma queues them.
WebSocket Flood: 500 Persistent Connections
| Metric | Phoenix LiveView | Rails Hotwire |
|---|---|---|
| Connections established | 500 / 500 | 500 / 500 |
| Connect p50 | 30 ms | 418 ms |
| Connect p95 | 49 ms | 704 ms |
| Connect p99 | 54 ms | 754 ms |
| Errors | 0 | 0 |
| Memory at 500 connections | ~70 MB | ~70 MB |
Both apps successfully held 500 concurrent WebSocket connections — a good result for Rails after the tuning work. But Phoenix established each connection ~14× faster (30ms vs 418ms at p50).
Memory was the surprise: nearly identical at ~70MB. The Erlang process-per-connection model is often described as memory-efficient, and it lives up to the claim — but so does Rails' threaded model at this scale.
The Rails WebSocket Journey
Getting Rails to 500/500 took three fixes that are worth understanding:
- Anonymous connections — Action Cable rejected ws_flood because it had no session cookie. Fixed by returning a temporary identity instead of rejecting.
-
PostgreSQL adapter — the default
asyncadapter is in-process only. With 2 Puma workers, broadcasts from one worker never reached clients on another. Switched to the PostgreSQL LISTEN/NOTIFY adapter. -
Origin header — Action Cable's forgery protection rejects WebSocket upgrades without a matching
Originheader. Disabled for development.
None of these are hacks — they're the correct production configuration. The Erlang VM had zero equivalent issues.
The Code Cost
Beyond performance, writing both apps revealed a stark difference in how much you have to build.
| What | Phoenix | Rails |
|---|---|---|
| WebSocket connections per tab | 1 | 2 |
| CRUD transport | WebSocket (same socket) | HTTP POST → Turbo Stream |
| Presence system |
Phoenix.Presence — 1 function call |
Custom RoomPresence class (~50 lines) |
| Real-time JS written | 0 lines | ~200 lines (3 Stimulus controllers) |
| Cross-process broadcasts | Built into Erlang PubSub | Requires Redis or Postgres LISTEN/NOTIFY |
The typing indicator is the clearest example. In Phoenix:
Presence.update(self(), topic, user.id, fn m -> %{m | typing: true} end)
Every connected client sees the update. Done.
In Rails, you write a Stimulus controller to detect keystrokes, send a typed message over the RoomChannel WebSocket, handle it server-side in room_channel.rb, broadcast a JSON blob, then write another Stimulus controller to receive it and update the DOM.
Both work. One is four steps, the other is one.
So Who Wins?
Phoenix wins if you're building real-time
If your app has WebSockets, live presence, collaborative features, or anything where concurrent connections matter — Phoenix is not marginally better, it's a different category. The numbers aren't close: 5× HTTP throughput, 14× faster WebSocket setup, zero JS for real-time, and a built-in distributed presence system that Just Works.
The Erlang VM was built for exactly this problem: massive concurrency, fault tolerance, and low-latency message passing. Phoenix LiveView is the most ergonomic wrapper that problem has ever had.
Rails wins if you're building everything else
The Rails ecosystem is enormous. Gems for every problem. Decades of Stack Overflow answers. A hiring pool that dwarfs the Elixir community. If you're building a standard web app — even one with some real-time features — Rails ships faster. Turbo Streams feel like magic for simple broadcast use cases, and you stay in a language (Ruby) that reads like English.
The performance gap also matters less than the numbers suggest at small scale. If you have 10 concurrent users, both frameworks feel instant.
The honest verdict
For real-time collaborative software: Phoenix. No contest.
For everything else: Rails. No apology needed.
The mistake is treating this as a binary choice about which framework is "better." They optimize for different things. Phoenix optimizes for concurrency — handling thousands of simultaneous connections efficiently. Rails optimizes for developer velocity — moving fast with a rich ecosystem.
The numbers I ran reflect that clearly. Phoenix is not a better Rails. It's a better Erlang/OTP web framework that happens to look friendly.
What I'd Do Differently
- Run benchmarks in production mode for both. Dev mode penalizes Rails more (no eager loading, code reloading overhead) and likely flatters the comparison.
- Test with Redis backing Rails — the PostgreSQL LISTEN/NOTIFY adapter adds latency that Redis wouldn't.
- Increase to 5,000 WebSocket connections to find where each breaks.
- Measure memory per connection more precisely — the ~70MB baseline includes the app itself.
The repository is open. Run it yourself, tune it differently, and let me know what you find.
Stack Details
| Phoenix | Rails | |
|---|---|---|
| Framework | Phoenix 1.7 + LiveView | Rails 8 + Hotwire |
| Server | Bandit | Puma (2 workers × 5 threads) |
| Database | PostgreSQL 16 | PostgreSQL 16 |
| Cable adapter | Erlang PubSub (built-in) | PostgreSQL LISTEN/NOTIFY |
| JS written | 0 lines | ~200 lines |
| Language | Elixir 1.17 / OTP 27 | Ruby 3.3.4 |
abhsss96
/
same-same-but-different
Same UX. Same features. Different stacks. Phoenix LiveView vs Rails Hotwire, head to head.
Phoenix LiveView vs Rails Hotwire
Source for the blog post: "Phoenix LiveView vs Rails Hotwire: What I learned building the same app twice."
Two identical collaborative todo boards, one in each stack. Same features, same HTML structure, same Tailwind classes. Different everything underneath.
phoenix_app/ Phoenix 1.7 · LiveView · Ecto · PostgreSQL · Bandit
rails_app/ Rails 8 · Turbo Streams · Stimulus · Action Cable · PostgreSQL · Puma
bench/ k6 HTTP + WebSocket scripts · Node.js flood script
shared/ architecture diagram (see below)
Architecture
┌──────────────────────────────┐ ┌──────────────────────────────────────┐
│ Phoenix LiveView │ │ Rails 8 + Hotwire │
│ │ │ │
│ Browser │ │ Browser │
│ └─ 1 WebSocket ────────────┼── │ ├─ WS #1: Turbo::StreamsChannel ───┐ │
│ ├─ LiveView process │ │ │ (todo HTML diffs) │ │
│ │ ├─ Presence.track │ │ └─ WS #2: RoomChannel ─────────────┤ │
│ │ ├─ PubSub.subscribe│ │ (presence +…
Top comments (0)