skills/thibautbaissac/rails_ai_agents/performance-optimization

performance-optimization

SKILL.md

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
Weekly Installs
16
GitHub Stars
407
First Seen
Jan 23, 2026
Installed on
opencode11
claude-code10
codex9
github-copilot8
gemini-cli8
cursor8