Performance Optimization for Rails 8
Overview
Performance optimization focuses on:
-
N+1 query detection and prevention
-
Query optimization
-
Memory management
-
Response time improvements
-
Database indexing
Quick Start
Gemfile
group :development, :test do gem 'bullet' # N+1 detection gem 'rack-mini-profiler' # Request profiling gem 'memory_profiler' # Memory analysis end
Bullet Configuration
config/environments/development.rb
config.after_initialize do Bullet.enable = true Bullet.alert = true Bullet.bullet_logger = true Bullet.console = true Bullet.rails_logger = true Bullet.add_footer = true
Raise errors in test
Bullet.raise = true
end
config/environments/test.rb
config.after_initialize do Bullet.enable = true Bullet.raise = true # Fail tests on N+1 end
N+1 Query Problems
The Problem
BAD: N+1 query - 1 query for events, N queries for venues
@events = Event.all @events.each do |event| puts event.venue.name # Query per event! end
Generated SQL:
SELECT * FROM events
SELECT * FROM venues WHERE id = 1
SELECT * FROM venues WHERE id = 2
SELECT * FROM venues WHERE id = 3
... (N more queries)
The Solution
GOOD: Eager loading - 2 queries total
@events = Event.includes(:venue) @events.each do |event| puts event.venue.name # No additional query end
Generated SQL:
SELECT * FROM events
SELECT * FROM venues WHERE id IN (1, 2, 3, ...)
Eager Loading Methods
includes (Preferred)
Single association
Event.includes(:venue)
Multiple associations
Event.includes(:venue, :organizer)
Nested associations
Event.includes(venue: :address) Event.includes(vendors: { category: :parent })
Deep nesting
Event.includes( :venue, :organizer, vendors: [:category, :reviews], comments: :user )
preload vs eager_load
preload: Separate queries (default for includes)
Event.preload(:venue)
SELECT * FROM events
SELECT * FROM venues WHERE id IN (...)
eager_load: Single LEFT JOIN query
Event.eager_load(:venue)
SELECT events., venues. FROM events LEFT JOIN venues ON ...
includes chooses automatically based on conditions
Event.includes(:venue).where(venues: { city: 'Paris' })
Uses LEFT JOIN because of WHERE condition on venue
When to Use Each
Method Use When
includes
Most cases (Rails chooses best strategy)
preload
Forcing separate queries, large datasets
eager_load
Filtering on association, need single query
joins
Only need to filter, don't need association data
Query Optimization Patterns
Pattern 1: Scoped Eager Loading
app/models/event.rb
class Event < ApplicationRecord scope :with_details, -> { includes(:venue, :organizer, vendors: :category) }
scope :with_stats, -> { select("events., (SELECT COUNT() FROM comments WHERE comments.event_id = events.id) as comments_count, (SELECT COUNT(*) FROM event_vendors WHERE event_vendors.event_id = events.id) as vendors_count") } end
Controller
@events = Event.with_details.where(account: current_account)
Pattern 2: Counter Caches
Migration
add_column :events, :comments_count, :integer, default: 0, null: false add_column :events, :vendors_count, :integer, default: 0, null: false
Model
class Comment < ApplicationRecord belongs_to :event, counter_cache: true end
class EventVendor < ApplicationRecord belongs_to :event, counter_cache: :vendors_count end
Usage - no query needed
event.comments_count event.vendors_count
Pattern 3: Select Only Needed Columns
BAD: Loads all columns
User.all.map(&:name)
GOOD: Loads only name
User.pluck(:name)
GOOD: For objects with limited columns
User.select(:id, :name, :email).map { |u| "#{u.name} <#{u.email}>" }
Pattern 4: Batch Processing
BAD: Loads all records into memory
Event.all.each { |e| process(e) }
GOOD: Processes in batches
Event.find_each(batch_size: 500) { |e| process(e) }
GOOD: For updates
Event.in_batches(of: 1000) do |batch| batch.update_all(status: :archived) end
Pattern 5: Exists? vs Any? vs Present?
BAD: Loads all records
if Event.where(status: :active).any? if Event.where(status: :active).present?
GOOD: SELECT 1 LIMIT 1
if Event.where(status: :active).exists?
GOOD: For checking count
if Event.where(status: :active).count > 0
Pattern 6: Size vs Count vs Length
count: Always queries database
events.count # SELECT COUNT(*) FROM events
size: Uses counter cache or count
events.size # Uses cached value if available
length: Uses loaded collection or loads all
events.length # Loads all records if not loaded
Best practices:
events.loaded? ? events.length : events.count
OR just use size (handles both cases)
Database Indexing
Finding Missing Indexes
Check for missing foreign key indexes
ActiveRecord::Base.connection.tables.each do |table| columns = ActiveRecord::Base.connection.columns(table) fk_columns = columns.select { |c| c.name.end_with?('_id') } indexes = ActiveRecord::Base.connection.indexes(table)
fk_columns.each do |col| indexed = indexes.any? { |idx| idx.columns.include?(col.name) } puts "Missing index: #{table}.#{col.name}" unless indexed end end
Index Types
Single column index
add_index :events, :status
Composite index (order matters!)
add_index :events, [:account_id, :status]
Unique index
add_index :users, :email, unique: true
Partial index
add_index :events, :event_date, where: "status = 0"
Covering index (PostgreSQL)
add_index :events, [:account_id, :status], include: [:name, :event_date]
When to Add Indexes
Add Index For Example
Foreign keys account_id , user_id
Columns in WHERE WHERE status = 'active'
Columns in ORDER BY ORDER BY created_at DESC
Columns in JOIN JOIN ON events.venue_id
Unique constraints email , uuid
Memory Optimization
Finding Memory Issues
In console or specs
require 'memory_profiler'
report = MemoryProfiler.report do
Code to profile
Event.includes(:venue, :vendors).to_a end
report.pretty_print
Memory-Efficient Patterns
BAD: Loads all records
Event.all.map(&:name).join(', ')
GOOD: Streams results
Event.pluck(:name).join(', ')
BAD: Builds large array
results = [] Event.find_each { |e| results << e.name }
GOOD: Uses Enumerator
Event.find_each.map(&:name)
Avoiding Memory Bloat
BAD: Instantiates all AR objects
Event.all.each do |event| event.update!(processed: true) end
GOOD: Direct SQL update
Event.update_all(processed: true)
GOOD: Batched updates
Event.in_batches.update_all(processed: true)
Query Analysis
EXPLAIN in Rails
Analyze query plan
Event.where(status: :active).explain
Analyze with format
Event.where(status: :active).explain(:analyze)
Logging Slow Queries
config/environments/production.rb
config.active_record.warn_on_records_fetched_greater_than = 1000
Custom slow query logging
ActiveSupport::Notifications.subscribe("sql.active_record") do |*args| event = ActiveSupport::Notifications::Event.new(*args) if event.duration > 100 # ms Rails.logger.warn("SLOW QUERY (#{event.duration.round}ms): #{event.payload[:sql]}") end end
Testing for Performance
N+1 Detection in Specs
spec/rails_helper.rb
RSpec.configure do |config| config.before(:each) do Bullet.start_request end
config.after(:each) do Bullet.perform_out_of_channel_notifications if Bullet.notification? Bullet.end_request end end
spec/requests/events_spec.rb
RSpec.describe "Events", type: :request do it "loads index without N+1" do create_list(:event, 5, :with_venue, :with_vendors)
expect {
get events_path
}.not_to raise_error # Bullet raises on N+1
end end
Query Count Assertions
spec/support/query_counter.rb
module QueryCounter def count_queries(&block) count = 0 counter = ->(*, _) { count += 1 } ActiveSupport::Notifications.subscribed(counter, "sql.active_record", &block) count end end
RSpec.configure do |config| config.include QueryCounter end
Usage
it "makes minimal queries" do events = create_list(:event, 5, :with_venue)
query_count = count_queries do Event.with_details.map { |e| e.venue.name } end
expect(query_count).to eq(2) # events + venues end
Rack Mini Profiler
Setup
Gemfile
gem 'rack-mini-profiler' gem 'stackprof' # For flamegraphs
config/initializers/rack_profiler.rb
if Rails.env.development? Rack::MiniProfiler.config.position = 'bottom-right' Rack::MiniProfiler.config.start_hidden = false end
Usage
-
Visit any page - profiler badge shows in corner
-
Click badge to see detailed breakdown
-
Add ?pp=flamegraph for flamegraph
-
Add ?pp=help for all options
Performance Checklist
Before Deployment
-
Bullet enabled in development/test
-
No N+1 queries in critical paths
-
Foreign keys have indexes
-
Counter caches for frequent counts
-
Eager loading in controllers
-
Batch processing for large datasets
-
Query analysis for slow endpoints
Monitoring Queries
app/controllers/application_controller.rb
around_action :log_query_count, if: -> { Rails.env.development? }
private
def log_query_count count = 0 counter = ->(*, _) { count += 1 } ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do yield end Rails.logger.info "QUERIES: #{count} for #{request.path}" end
Quick Fixes Reference
Problem Solution
N+1 on belongs_to includes(:association)
N+1 on has_many includes(:association)
Slow COUNT Add counter_cache
Loading all columns Use select or pluck
Large dataset iteration Use find_each
Missing index on FK Add index on *_id columns
Slow WHERE clause Add index on filtered column
Loading unused associations Remove from includes