Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 048 / 100

Rails performance tuning: a hands-on guide for engineers

Faster responses in production.

Expona Admin7 min read

Rails performance tuning: a hands-on guide for engineers

Most Rails applications feel snappy in development and then grind to a halt once real traffic hits production. We've all watched response times creep from 100ms to 2000ms as request volume climbs, and traced the culprit back to a single N+1 query or missing index. Performance doesn't happen by accident—it requires systematic measurement, targeted optimisation, and a willingness to revisit architectural decisions as load grows.

This guide walks through Rails performance tuning: a hands-on guide for engineers who need concrete techniques, not theory. We'll cover database optimisation, caching strategies, background job patterns, and profiling tools that work in real production environments. Whether you're operating a high-traffic e-commerce platform, a SaaS application with thousands of concurrent users, or a ride-hailing service processing millions of events daily, these patterns will help you identify bottlenecks and eliminate them.

Database: Indexes, includes, and fewer queries

The database is usually where performance problems live. Before touching caching or background jobs, fix the queries themselves.

Identifying the N+1 query trap

The classic Rails mistake: you fetch a collection and then access an association in a loop.

ruby
# Bad: N+1 queries
users = User.all
users.each do |user|
  puts user.posts.count
end
# Executes 1 query for users, then 1 query per user for posts

The fix is includes, which eager-loads the association in a single additional query:

ruby
# Good: 2 queries total
users = User.includes(:posts).all
users.each do |user|
  puts user.posts.count
end

For nested associations, chain includes:

ruby
users = User.includes(posts: [:comments, :tags]).all

When you need to filter based on an association, use joins with distinct to avoid duplicates:

ruby
users = User.joins(:posts).where(posts: { published: true }).distinct

Adding indexes strategically

A missing index on a frequently filtered column causes full table scans. Check your logs and application metrics for slow queries, then add indexes where they matter.

ruby
class AddIndexesToPosts < ActiveRecord::Migration[7.0]
  def change
    add_index :posts, :user_id
    add_index :posts, :published_at
    add_index :posts, [:user_id, :published_at]
  end
end

Composite indexes (multiple columns) are powerful. If you frequently query where(user_id: X).order(published_at: :desc), the two-column index above eliminates a sort operation inside the database.

Don't index everything. Each index slows writes (INSERT, UPDATE, DELETE). Profile first, then index what actually speeds queries.

Reducing query volume with select and pluck

Fetch only the columns you need. If you're building a dropdown list of user names, don't load the entire user record with all associations:

ruby
# Inefficient
User.all.map { |u| u.name }

# Better: only fetches the name column
User.pluck(:name)

# If you need the ID too
User.pluck(:id, :name)

For complex projections, use select:

ruby
User.select(:id, :name, :email).where(active: true)

Caching: Fragment and low-level caching

Once queries are optimised, caching shields you from re-computing expensive results on every request.

Fragment caching for view partials

Fragment caching stores rendered HTML in Redis or Memcached. It's perfect for expensive view logic that doesn't change every request.

ruby
<% cache "products_list_v1", expires_in: 1.hour do %>
  <div class="products">
    <% @products.each do |product| %>
      <%= render product %>
    <% end %>
  </div>
<% end %>

The v1 suffix is a cache buster—increment it when the partial changes. When a product updates, invalidate the fragment:

ruby
class Product < ApplicationRecord
  after_update :clear_cache
  
  private
  
  def clear_cache
    Rails.cache.delete("products_list_v1")
  end
end

For collections with per-item rendering, use automatic fragment caching:

ruby
<%= render @products %>

Rails automatically caches each product partial if it defines a cache key. In the partial:

ruby
<% cache product do %>
  <div class="product">
    <h3><%= product.name %></h3>
    <p><%= product.description %></p>
  </div>
<% end %>

Low-level caching with Rails.cache

Cache expensive computations, external API calls, or complex queries:

ruby
class UserService
  def user_summary(user_id)
    Rails.cache.fetch("user_summary_#{user_id}", expires_in: 30.minutes) do
      user = User.find(user_id)
      posts_count = user.posts.count
      followers_count = user.followers.count
      
      {
        id: user.id,
        name: user.name,
        posts_count: posts_count,
        followers_count: followers_count
      }
    end
  end
end

The fetch method checks the cache first; if a miss occurs, it computes the value and stores it. This pattern keeps your code simple and avoids the check-then-set race condition.

For cache invalidation, use touch on model associations to automatically bust related caches:

ruby
class Post < ApplicationRecord
  belongs_to :user, touch: true
end

# When a post is created or updated, user.updated_at changes,
# invalidating any cache keyed on user.cache_key_with_version

Background jobs: Move slow work out of requests

Some tasks don't belong in HTTP requests: sending emails, processing images, calling external APIs, running reports. Push them to a job queue.

Enqueueing with ActiveJob

Define a job class:

ruby
class SendWelcomeEmailJob < ApplicationJob
  queue_as :mailers
  
  def perform(user_id)
    user = User.find(user_id)
    UserMailer.welcome(user).deliver_now
  end
end

Enqueue it from anywhere in your application:

ruby
SendWelcomeEmailJob.perform_later(user.id)

The job is stored in a queue (Redis, Sidekiq, or another backend) and processed asynchronously. Your controller returns immediately to the user.

Batching and priority queues

For high-volume scenarios, batch processing saves database roundtrips and improves throughput:

ruby
class BulkProcessOrdersJob < ApplicationJob
  queue_as :default
  
  def perform(order_ids)
    orders = Order.where(id: order_ids).to_a
    orders.each do |order|
      order.process!
    end
  end
end

# Enqueue once with multiple orders
BulkProcessOrdersJob.perform_later([1, 2, 3, 4, 5])

For critical tasks, use priority queues:

ruby
class CriticalAlertJob < ApplicationJob
  queue_as :critical
  
  def perform(message)
    # Handle urgently
  end
end

Configure your job processor (Sidekiq) to prioritise the critical queue.

Profile: rack-mini-profiler and APM tools

You can't optimise what you don't measure. Profiling shows exactly where time is spent.

Using rack-mini-profiler in development

Add the gem and it shows a speed badge in the top-left corner of every page:

ruby
gem 'rack-mini-profiler'
gem 'sqlite3' # or redis for shared profiling storage

Click the badge to see a breakdown of time spent in Rails, database queries, garbage collection, and view rendering. It reveals N+1 queries instantly—they appear as duplicate SQL statements in the profile.

Production profiling with APM tools

In production, use an Application Performance Monitoring (APM) tool like New Relic, DataDog, or Dynatrace. These agents instrument your application automatically, tracking:

  • Request latency distribution (p50, p95, p99)
  • Slow transactions and their call stacks
  • Database query performance across all instances
  • External API response times
  • Memory and CPU usage

A typical workflow: APM alerts you to a spike in response times. You click through to the slow transaction, see it's spending 80% of time in a database query, and add an index. Response time drops back to normal.

For smaller teams, structured logging with a tool like CloudWatch or Elasticsearch works well. Log slow requests and query duration, then query logs to find patterns:

ruby
class LogSlowRequestsMiddleware
  def initialize(app)
    @app = app
  end
  
  def call(env)
    start_time = Time.now
    @app.call(env)
  ensure
    elapsed = Time.now - start_time
    if elapsed > 1.0
      Rails.logger.warn("Slow request", 
        path: env['PATH_INFO'],
        method: env['REQUEST_METHOD'],
        elapsed_ms: (elapsed * 1000).round
      )
    end
  end
end

Common mistakes

  • Caching without a strategy: Aggressive caching creates stale data. Always define TTLs and invalidation rules upfront.
  • Optimising before measuring: Guessing where time is spent wastes effort. Profile first, optimise second.
  • Ignoring background job monitoring: Jobs can fail silently. Use Sidekiq Web or similar to monitor queue depth and failure rates.
  • Adding indexes for every query: Too many indexes slow writes and bloat the database. Index the queries that matter most.
  • Caching user-specific data globally: If you cache a user's personalised dashboard, ensure the cache key includes the user ID to avoid serving one user's data to another.

Key takeaways

  • Eliminate N+1 queries with includes, joins, and strategic indexing before caching anything.
  • Fragment caching and low-level caching with Rails.cache.fetch reduce re-computation; plan invalidation carefully.
  • Move slow work (emails, external APIs, heavy computation) to background jobs with ActiveJob and a queue backend.
  • Profile in development with rack-mini-profiler and in production with APM tools or structured logging to guide optimisation efforts.
  • Fix the database first, cache second, move work to background jobs third—in that order for maximum impact.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles