>_rails inside
~/rails-inside $ cat blog/refactoring-legacy-rails-applications.html
HomePostsAbout
// 03  PERFORMANCE  ////  Mar 11, 2026  ////  7 min

How to Refactor Legacy Rails Applications for Performance

A systematic approach to refactoring legacy Rails codebases: identifying bottlenecks, untangling fat models, and improving query performance without rewriting everything.

Most Rails developers eventually inherit a codebase that has accumulated years of shortcuts. Models with 800-line files, controllers handling business logic, missing database indexes, and N+1 queries in every action. The question is never whether to refactor, it is how to do it without shipping regressions and without losing months to a ground-up rewrite that never ships.

This guide covers a disciplined, measurable approach to Rails refactoring with a focus on performance payoffs that are visible to end users.

Start with Measurement, Not Opinion

Before touching a line of code, establish a baseline. Opinions about what is slow are almost always wrong. Add rack-mini-profiler to the development environment and enable it against a production database dump:

group :development do
  gem "rack-mini-profiler"
  gem "flamegraph"
  gem "stackprof"
end

In production, Skylight and Scout APM both provide per-endpoint timing with minimal overhead. Export the slowest 20 endpoints and commit that list to a file in the repository. Every refactoring session should move at least one endpoint off the list.

Database Indexes: The Highest Return Work

Missing indexes are the most common performance problem in legacy Rails applications and the easiest to fix. The lol_dba gem scans your schema and reports missing indexes based on foreign keys and common query patterns:

bundle exec rake db:find_indexes

Add the indexes in a migration with algorithm: :concurrently to avoid locking production tables:

class AddIndexToOrdersUserId < ActiveRecord::Migration[7.2]
  disable_ddl_transaction!

  def change
    add_index :orders, :user_id, algorithm: :concurrently
  end
end

A missing index on a foreign key in a table with a million rows typically means a full sequential scan on every association load. Fixing it is a one-line migration that takes seconds.

Eliminating N+1 Queries

N+1 queries are the second most common source of latency. The bullet gem surfaces them in development logs and can be configured to raise exceptions:

# config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.raise = true
  Bullet.add_footer = true
end

The fix is almost always includes or preload:

# Before, fires one query per post
@posts = Post.published.limit(20)

# After, two queries total
@posts = Post.published.includes(:author, :tags).limit(20)

Be precise with includes. Loading every association on a model to avoid N+1s creates wide result sets that slow down everything else. Profile before and after each change.

Decomposing Fat Models

The Rails convention of "fat models, skinny controllers" has a failure mode: models that handle persistence, business logic, third-party API calls, email delivery, and background job scheduling. A model file over 300 lines almost certainly has this problem.

The correct decomposition depends on the type of logic:

  • Business rules that operate on a single model go into a Service Object.
  • Multi-step workflows that touch several models go into a Command Object or a process class.
  • Reusable query logic goes into a Query Object.
# app/services/order_fulfillment_service.rb
class OrderFulfillmentService
  def initialize(order)
    @order = order
  end

  def call
    ActiveRecord::Base.transaction do
      @order.reserve_inventory!
      @order.charge_payment!
      @order.update!(status: :confirmed)
      OrderMailer.confirmation(@order).deliver_later
    end
  end
end

The model retains reserve_inventory! and charge_payment! as focused state-transition methods. The service object owns the orchestration. This split makes each class independently testable.

Query Objects for Complex Scopes

Named scopes that chain three or more conditions, or that join multiple tables, belong in a dedicated query object rather than a model scope:

# app/queries/active_subscriber_query.rb
class ActiveSubscriberQuery
  def initialize(relation = User.all)
    @relation = relation
  end

  def call
    @relation
      .joins(:subscription)
      .where(subscriptions: { status: :active, plan: :paid })
      .where("subscriptions.expires_at > ?", Time.current)
      .order("users.created_at DESC")
  end
end

# Usage
ActiveSubscriberQuery.new.call
ActiveSubscriberQuery.new(User.where(country: "US")).call

This pattern makes query logic reusable and eliminates the User.active_paid_subscribers_in_good_standing scope chain that is impossible to test in isolation.

Caching Without Over-Engineering

Fragment caching with Russian Doll cache keys is the right first caching layer for most Rails applications. Enable it in production and ensure cache keys include updated_at:

# In a view partial
<% cache @article do %>
  <%= render partial: "article_body", locals: { article: @article } %>
<% end %>

Rails automatically invalidates the cache when @article.updated_at changes. The key mistake is caching database queries rather than rendered fragments. Query caching produces stale data that is hard to reason about; fragment caching scopes invalidation to the record's own lifecycle.

Upgrading Rails Itself

Running a legacy Rails version is itself a performance liability. Rails 7.1 and later ship Solid Cache and Solid Queue as first-party options, and the query planner improvements in recent versions of Active Record reduce object allocation significantly.

The official upgrade guide covers each version pair. The practical approach is to use the next_rails gem to run your test suite against the next Rails version before changing the Gemfile:

bundle exec next rake test

Fix deprecation warnings one Rails version at a time. Attempting a multi-version jump without intermediate stops creates a debugging problem that consumes the productivity gains the upgrade was supposed to deliver.

Tracking Progress

Every refactoring effort needs an exit condition. Before starting, document the three metrics that matter most: p95 response time for the five slowest endpoints, the number of N+1 queries flagged by Bullet in a full test run, and the number of model files over 300 lines. Review those numbers weekly. When all three are at target, the refactoring phase is complete.

For tooling recommendations that support these patterns, see The Best Ruby on Rails Plugins for Modern Web Development and the security hardening guidance in Essential Security Best Practices for Rails Developers.