Back to Scaling Ruby Applications guides

Ruby on Rails vs Phoenix

Stanley Ulili
Updated on September 22, 2025

Choosing a web framework is not just about syntax, it is about the trade-offs that define your app’s future. Do you want the speed of development that made Ruby on Rails famous, or the real-time performance that powers Phoenix?

Rails has spent two decades perfecting conventions that let developers ship features in hours, not weeks. Phoenix, built on Elixir and the battle-tested Erlang VM, brings unmatched scalability and live updates that today’s users expect.

Both frameworks are developer-friendly, but they prioritize different goals. Rails maximizes productivity through conventions, while Phoenix pushes performance and concurrency to the forefront. Picking one shapes everything from your development workflow to how your app scales under load.

What is Ruby on Rails?

Screenshot of Ruby on Rails Github page

Rails solved the biggest problem in 2004 web development: every developer was rebuilding the same features over and over. User authentication, database migrations, form handling, and URL routing had to be written from scratch for each project. Rails bundled all these common needs into one framework with sensible defaults.

The magic happens through conventions that eliminate decisions. Put your models in the models folder, controllers in controllers, and Rails knows how to connect everything. Need user management? Generate a scaffold and get working code in seconds. Want to add a new feature? Follow the established patterns and everything just works.

Rails handles database relationships, validation, scoping, authentication, and error handling without any configuration. The framework makes assumptions about what you need and provides it automatically.

What is Phoenix?

Screenshot of Phoenix Github page

Phoenix was created because modern web applications need capabilities that traditional frameworks struggle with. Users expect real-time updates, instant responses, and applications that stay fast under heavy load. Phoenix delivers these features while keeping development simple and enjoyable.

The secret is Elixir and the Erlang virtual machine, which were designed for telecommunications systems that never go down. Your Phoenix app runs millions of lightweight processes that can communicate instantly. When a user action needs to update other users in real-time, Phoenix makes it effortless.

Phoenix code looks similar to Rails but runs on a completely different foundation. Pattern matching makes error handling explicit and reliable, while the underlying virtual machine handles massive concurrency automatically.

Framework comparison

Understanding what each framework prioritizes helps you pick the right tool for your project.

Aspect Ruby on Rails Phoenix
Language Ruby - readable, expressive Elixir - functional, pattern matching
Performance Good for most apps, slower under high load Exceptional, handles millions of connections
Learning Curve Gentle, lots of tutorials and books Steeper, functional programming concepts
Development Speed Very fast, lots of generators and gems Fast, but fewer ready-made solutions
Real-time Features ActionCable and Hotwire Turbo Built-in LiveView and Channels
Community Huge, mature ecosystem (15+ years) Growing, enthusiastic but smaller (10+ years)
Hosting Works everywhere, many deployment options Fewer hosting options, containerization common
Job Market Lots of Rails jobs available Fewer Phoenix jobs, but growing demand
Error Handling Exceptions, can crash entire request Supervised processes, failures stay isolated
Database ActiveRecord, works with any SQL database Ecto, PostgreSQL recommended

Your choice depends on what matters most for your project. Rails excels when you need to ship features quickly and have a team that knows Ruby. Phoenix shines when performance and real-time features are critical.

Getting started

Let's see how quickly you can get a working application with each framework.

Rails gets you from zero to running app in minutes:

 
gem install rails
rails new blog_app
cd blog_app
rails generate scaffold Article title:string content:text published:boolean
rails db:migrate
rails server

This creates a complete blog application with database, forms, and styling. You can immediately create, edit, and delete articles through a web interface.

app/models/article.rb
class Article < ApplicationRecord
  belongs_to :user
  has_many :comments, dependent: :destroy

  validates :title, presence: true
  validates :content, presence: true, length: { minimum: 10 }

  scope :published, -> { where(published: true) }
  scope :recent, -> { order(created_at: :desc) }
end
app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  before_action :authenticate_user!, except: [:index, :show]
  before_action :set_article, only: [:show, :edit, :update, :destroy]

  def index
    @articles = Article.published.recent.includes(:user)
  end

  def create
    @article = current_user.articles.build(article_params)

    if @article.save
      redirect_to @article, notice: 'Article created successfully!'
    else
      render :new
    end
  end

  private

  def article_params
    params.require(:article).permit(:title, :content, :published)
  end
end
config/routes.rb
Rails.application.routes.draw do
  resources :articles
  root 'articles#index'
end

Rails generates everything you need: database migrations, model validations, controller actions, HTML views, and CSS styling. You get a working application without writing any code yourself.

Phoenix requires a bit more setup but gives you a solid foundation:

 
mix archive.install hex phx_new
mix phx.new blog_app
cd blog_app
mix ecto.setup
mix phx.gen.html Blog Article articles title:string content:text published:boolean
mix ecto.migrate
mix phx.server

Phoenix generates similar functionality but with different patterns:

lib/blog/article.ex
defmodule Blog.Article do
  use Ecto.Schema
  import Ecto.Changeset

  schema "articles" do
    field :title, :string
    field :content, :string
    field :published, :boolean, default: false

    belongs_to :user, Blog.User
    has_many :comments, Blog.Comment

    timestamps()
  end

  def changeset(article, attrs) do
    article
    |> cast(attrs, [:title, :content, :published])
    |> validate_required([:title, :content])
    |> validate_length(:content, min: 10)
  end
end
lib/blog_web/controllers/article_controller.ex
defmodule BlogWeb.ArticleController do
  use BlogWeb, :controller

  alias Blog.Articles

  def index(conn, _params) do
    articles = Articles.list_published_articles()
    render(conn, "index.html", articles: articles)
  end

  def create(conn, %{"article" => article_params}) do
    case Articles.create_article(article_params) do
      {:ok, article} ->
        conn
        |> put_flash(:info, "Article created successfully!")
        |> redirect(to: ~p"/articles/#{article}")

      {:error, changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end
end
lib/blog_web/router.ex
defmodule BlogWeb.Router do
  use BlogWeb, :router

  scope "/", BlogWeb do
    pipe_through :browser

    get "/", PageController, :index
    resources "/articles", ArticleController
  end
end

Phoenix generators create similar structure to Rails but use Elixir patterns. The result is a working web application that can handle much higher traffic than the Rails version.

Real-time features

Modern web apps need to update instantly when data changes. This is where the two frameworks differ most dramatically.

Rails provides real-time features through ActionCable, but it requires careful setup:

app/channels/comments_channel.rb
class CommentsChannel < ApplicationCable::Channel
  def subscribed
    stream_from "article_#{params[:article_id]}_comments"
  end

  def unsubscribed
    # Cleanup code here
  end
end
app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :article

  after_create_commit do
    ActionCable.server.broadcast(
      "article_#{article_id}_comments",
      comment: CommentsController.render(partial: 'comment', locals: { comment: self })
    )
  end
end
app/javascript/channels/comments_channel.js
import consumer from "./consumer"

consumer.subscriptions.create({ 
  channel: "CommentsChannel", 
  article_id: window.articleId 
}, {
  connected() {
    console.log("Connected to comments channel")
  },

  received(data) {
    document.querySelector("#comments").insertAdjacentHTML('beforeend', data.comment)
  }
})

ActionCable works but requires coordinating between Ruby, JavaScript, and WebSocket connections. Each piece needs careful configuration to work together.

Phoenix makes real-time features simple with LiveView:

lib/blog_web/live/article_live.ex
defmodule BlogWeb.ArticleLive do
  use BlogWeb, :live_view

  alias Blog.Articles

  def mount(%{"id" => id}, _session, socket) do
    if connected?(socket), do: Articles.subscribe_to_comments(id)

    article = Articles.get_article!(id)
    {:ok, assign(socket, :article, article)}
  end

  def handle_event("add_comment", %{"comment" => comment_params}, socket) do
    case Articles.create_comment(socket.assigns.article, comment_params) do
      {:ok, _comment} ->
        {:noreply, put_flash(socket, :info, "Comment added!")}
      {:error, _changeset} ->
        {:noreply, put_flash(socket, :error, "Could not add comment")}
    end
  end

  def handle_info({:comment_created, comment}, socket) do
    updated_article = Articles.get_article!(socket.assigns.article.id)
    {:noreply, assign(socket, :article, updated_article)}
  end
end
lib/blog_web/live/article_live.html.heex
<div>
  <h1><%= @article.title %></h1>
  <p><%= @article.content %></p>

  <div id="comments">
    <%= for comment <- @article.comments do %>
      <div class="comment">
        <p><%= comment.body %></p>
      </div>
    <% end %>
  </div>

  <form phx-submit="add_comment">
    <input type="text" name="comment[body]" placeholder="Add a comment..." />
    <button type="submit">Post Comment</button>
  </form>
</div>

LiveView handles all the real-time complexity for you. When someone adds a comment, all users see it instantly without any JavaScript or WebSocket configuration. The HTML updates automatically.

Database and querying

How you work with data shapes your entire application. Rails and Phoenix take different approaches to database interaction.

Rails uses ActiveRecord, which tries to hide database complexity:

app/models/article.rb
class Article < ApplicationRecord
  belongs_to :user
  has_many :comments, dependent: :destroy
  has_many :tags, through: :article_tags

  validates :title, presence: true, uniqueness: true
  validates :content, presence: true

  scope :published, -> { where(published: true) }
  scope :popular, -> { joins(:comments).group(:id).having('count(comments.id) > 5') }

  def self.search(query)
    where("title ILIKE ? OR content ILIKE ?", "%#{query}%", "%#{query}%")
  end
end
app/controllers/articles_controller.rb
def index
  @articles = Article.published
                    .includes(:user, :comments)
                    .where(created_at: 1.month.ago..)
                    .order(created_at: :desc)
                    .page(params[:page])
end

ActiveRecord lets you write Ruby code that becomes SQL queries. It prevents many SQL injection attacks and handles database differences automatically. However, complex queries can become hard to optimize.

Phoenix uses Ecto, which makes database queries explicit and composable:

lib/blog/articles.ex
defmodule Blog.Articles do
  import Ecto.Query
  alias Blog.{Repo, Article, Comment}

  def list_published_articles do
    Article
    |> where([a], a.published == true)
    |> where([a], a.inserted_at > ago(1, "month"))
    |> order_by([a], desc: a.inserted_at)
    |> preload([:user, :comments])
    |> Repo.all()
  end

  def search_articles(query) do
    search_term = "%#{query}%"

    Article
    |> where([a], ilike(a.title, ^search_term) or ilike(a.content, ^search_term))
    |> where([a], a.published == true)
    |> Repo.all()
  end

  def popular_articles do
    Comment
    |> group_by([c], c.article_id)
    |> having([c], count(c.id) > 5)
    |> select([c], c.article_id)
    |> join(:inner, [c], a in Article, on: c.article_id == a.id)
    |> preload([c, a], [article: [:user]])
    |> Repo.all()
  end
end

Ecto queries look more like SQL but with Elixir syntax. This makes performance optimization easier because you can see exactly what database operations happen. The ^ pins variables to prevent injection attacks.

Performance under load

Real applications need to handle traffic spikes, slow database queries, and user growth. This is where Rails and Phoenix show their biggest differences.

Rails performance depends heavily on configuration and caching:

config/environments/production.rb
Rails.application.configure do
  config.cache_classes = true
  config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] }

  # Enable serving of images, stylesheets, and JavaScripts from an asset server.
  config.asset_host = ENV['CDN_URL']

  # Use a different logger for distributed setups.
  config.logger = ActiveSupport::TaggedLogging.new(Logger.new(STDOUT))
end
app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Rails.cache.fetch("articles/published", expires_in: 5.minutes) do
      Article.published.includes(:user).limit(20)
    end
  end

  def show
    @article = Rails.cache.fetch("article/#{params[:id]}", expires_in: 1.hour) do
      Article.find(params[:id])
    end
  end
end

Rails apps scale through caching, background jobs, and adding more servers. Each Rails process handles one request at a time, so you need multiple processes to handle concurrent users.

Phoenix handles concurrency differently through the actor model:

lib/blog_web/controllers/article_controller.ex
defmodule BlogWeb.ArticleController do
  use BlogWeb, :controller

  def index(conn, _params) do
    # This can handle thousands of concurrent requests
    articles = Blog.Articles.list_published_articles()
    render(conn, "index.html", articles: articles)
  end
end
lib/blog/cache.ex
defmodule Blog.Cache do
  use GenServer

  def start_link(opts) do
    GenServer.start_link(__MODULE__, %{}, opts)
  end

  def get(key) do
    GenServer.call(__MODULE__, {:get, key})
  end

  def put(key, value) do
    GenServer.cast(__MODULE__, {:put, key, value})
  end

  def handle_call({:get, key}, _from, state) do
    {:reply, Map.get(state, key), state}
  end

  def handle_cast({:put, key, value}, state) do
    {:noreply, Map.put(state, key, value)}
  end
end

Phoenix applications can handle hundreds of thousands of concurrent connections on a single server. When one request is waiting for the database, thousands of other requests continue processing.

Testing

Every production application needs thorough testing to catch bugs before users do. Rails and Phoenix provide different testing tools and philosophies.

Rails testing is comprehensive and convention-based:

test/models/article_test.rb
require 'test_helper'

class ArticleTest < ActiveSupport::TestCase
  test "should not save article without title" do
    article = Article.new(content: "Some content")
    assert_not article.save
    assert_includes article.errors[:title], "can't be blank"
  end

  test "should create article with valid attributes" do
    article = Article.new(
      title: "Test Article",
      content: "This is test content",
      user: users(:john)
    )
    assert article.save
    assert_equal "Test Article", article.title
  end
end
test/controllers/articles_controller_test.rb
require 'test_helper'

class ArticlesControllerTest < ActionDispatch::IntegrationTest
  setup do
    @article = articles(:one)
    @user = users(:john)
  end

  test "should get index" do
    get articles_url
    assert_response :success
    assert_select "h1", "Articles"
  end

  test "should create article when logged in" do
    sign_in @user

    assert_difference('Article.count') do
      post articles_url, params: { 
        article: { title: "New Article", content: "Content here" }
      }
    end

    assert_redirected_to article_url(Article.last)
  end
end

Rails testing covers models, controllers, and integration flows with helpful assertions and fixtures. The testing framework knows about Rails conventions and provides shortcuts for common testing patterns.

Phoenix testing emphasizes pattern matching and explicit assertions:

test/blog/articles_test.exs
defmodule Blog.ArticlesTest do
  use Blog.DataCase

  alias Blog.Articles

  describe "create_article/1" do
    test "creates article with valid data" do
      valid_attrs = %{title: "Test Article", content: "Test content"}

      assert {:ok, %Article{} = article} = Articles.create_article(valid_attrs)
      assert article.title == "Test Article"
      assert article.content == "Test content"
    end

    test "returns error with invalid data" do
      assert {:error, %Ecto.Changeset{}} = Articles.create_article(%{})
    end
  end
end
test/blog_web/controllers/article_controller_test.exs
defmodule BlogWeb.ArticleControllerTest do
  use BlogWeb.ConnCase

  describe "GET /articles" do
    test "lists all articles", %{conn: conn} do
      article = insert(:article, title: "Test Article")

      conn = get(conn, Routes.article_path(conn, :index))

      assert html_response(conn, 200) =~ "Test Article"
    end
  end

  describe "POST /articles" do
    test "creates article with valid data", %{conn: conn} do
      article_params = %{title: "New Article", content: "Content"}

      conn = post(conn, Routes.article_path(conn, :create), article: article_params)

      assert %{id: id} = redirected_params(conn)
      assert redirected_to(conn) == Routes.article_path(conn, :show, id)
    end
  end
end

Phoenix tests use pattern matching to verify exact return values. The {:ok, article} and {:error, changeset} patterns make success and failure cases explicit.

Final thoughts

This article showed you the practical differences between Rails and Phoenix for building web applications. Each framework solves different problems well. The decision often comes down to you requirements. If you need to move fast and your team knows Ruby, Rails will get you there quicker. If you need performance and real-time features, Phoenix provides capabilities that Rails can't match easily.

Got an article suggestion? Let us know
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.