DEV Community

Abhishek Sharma
Abhishek Sharma

Posted on

Phoenix LiveView vs Rails Hotwire: I Built the Same Real-Time App in Both. The Numbers Aren't Close.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. k6 HTTP — ramps from 10 → 100 virtual users, each loading the room page then creating a todo
  2. k6 WebSocket — ramps from 50 → 500 VUs, each connecting and subscribing to a room
  3. 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:

  1. Anonymous connections — Action Cable rejected ws_flood because it had no session cookie. Fixed by returning a temporary identity instead of rejecting.
  2. PostgreSQL adapter — the default async adapter is in-process only. With 2 Puma workers, broadcasts from one worker never reached clients on another. Switched to the PostgreSQL LISTEN/NOTIFY adapter.
  3. Origin header — Action Cable's forgery protection rejects WebSocket upgrades without a matching Origin header. 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)
Enter fullscreen mode Exit fullscreen mode

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

GitHub logo 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)