Active Record Patterns That Keep Rails Apps Fast

Active Record Patterns That Keep Rails Apps Fast

Rails makes database access feel effortless—until it doesn't. A page that worked fine with 100 users slows down at 10,000 because of N+1 queries, missing indexes, or callbacks doing too much work. The good news: most Rails performance wins come from a handful of repeatable patterns.

Kill N+1 Queries First

The classic mistake: loading a list of records, then hitting the database again for each associated record.

# Bad — one query per project
@projects = current_user.projects
@projects.each { |p| puts p.client.name }

# Good — two queries total
@projects = current_user.projects.includes(:client)

Use includes for most cases. Reach for preload when you don't need to filter on the association, or eager_load when you do:

Project.includes(:client).where(clients: { active: true })

Add the bullet gem in development to catch N+1s before they reach production.

Index What You Query

Active Record can't fix a missing index. If your scopes filter or sort on a column, index it:

add_index :orders, :user_id
add_index :orders, [:status, :created_at]
add_index :products, :slug, unique: true

Review slow query logs regularly. EXPLAIN ANALYZE on PostgreSQL tells you exactly what the planner is doing.

Avoid .count in Loops

# Bad
users.each { |u| puts u.posts.count }

# Good — load counts in one query
users_with_counts = User.left_joins(:posts)
  .group(:id)
  .select("users.*, COUNT(posts.id) AS posts_count")

For simple cases, counter_cache on the association is even cleaner:

# migration
add_column :users, :posts_count, :integer, default: 0, null: false

# model
belongs_to :user, counter_cache: true

Use find_each for Batch Processing

Never iterate millions of rows with .each:

# Bad — loads everything into memory
User.where(inactive: true).each { |u| u.archive! }

# Good — batches of 1000
User.where(inactive: true).find_each(batch_size: 1000) { |u| u.archive! }

For heavy backfills, use background jobs and idempotent tasks so failures can retry safely.

Keep Callbacks Thin

after_save callbacks that send emails, sync search indexes, and charge cards turn models into god objects. Prefer explicit service objects or jobs:

# app/services/orders/complete.rb
class Orders::Complete
  def call(order)
    order.transaction do
      order.update!(status: :completed)
      OrderMailer.receipt(order).deliver_later
      SearchIndexJob.perform_later(order.id)
    end
  end
end

Callbacks for simple normalization (before_validation :strip_whitespace) are fine. Side effects belong elsewhere.

Select Only What You Need

Large text columns and JSON blobs add up:

# Bad — pulls every column including huge :body fields
Post.published.limit(50)

# Good — list view only needs metadata
Post.published.select(:id, :title, :slug, :published_at).limit(50)

Use dedicated serializers or as_json(only: [...]) for APIs with strict payload size requirements.

Cache Expensive Reads

Fragment caching works well with Russian doll caching for nested partials:

<% cache project do %>
  <%= render project %>
<% end %>

For aggregate stats that change infrequently, use Rails.cache.fetch:

Rails.cache.fetch("dashboard/stats", expires_in: 5.minutes) do
  { users: User.count, revenue: Order.paid.sum(:total) }
end

Rails 8's Solid Cache makes Redis optional for many apps.

Background Jobs for Slow Work

Anything over ~200ms belongs off the request cycle:

  • PDF generation
  • Third-party API calls
  • Bulk imports
  • Webhook delivery

Solid Queue (Rails 8 default) or Sidekiq keeps users seeing fast responses while work happens asynchronously.

Monitor Before You Optimize

Tools worth adding early:

  • Scout, Skylight, or AppSignal — Request-level profiling
  • PgHero — Index and query insights for PostgreSQL
  • Lograge — Structured request logs

Measure first. Optimizing the wrong layer wastes time.

When to Worry About Scale

Most Rails apps never need sharding or read replicas. Fix queries, add indexes, cache hot paths, and move slow work to jobs. That covers the vast majority of production slowdowns.

If you're outgrowing a single database, read replicas and connection pooling (PgBouncer) come before rewriting in another language.

Wrapping Up

Rails performance isn't magic—it's disciplined database access, thin models, and async work where it belongs. Nail these patterns early and your app stays fast as traffic grows.

Running a slow Rails app or planning a new build? Contact NekoCoding for a performance review or architecture consult.