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.
# 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:
# Good: 2 queries total
users = User.includes(:posts).all
users.each do |user|
puts user.posts.count
end
For nested associations, chain includes:
users = User.includes(posts: [:comments, :tags]).all
When you need to filter based on an association, use joins with distinct to avoid duplicates:
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.
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:
# 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:
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.
<% 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:
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:
<%= render @products %>
Rails automatically caches each product partial if it defines a cache key. In the partial:
<% 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:
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:
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:
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:
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:
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:
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:
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:
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.fetchreduce 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.






Comments
Loading comments…