or run

tessl search
Log in

performance-optimization

tessl install github:ThibautBaissac/rails_ai_agents --skill performance-optimization

github.com/ThibautBaissac/rails_ai_agents

Identifies and fixes Rails performance issues including N+1 queries, slow queries, and memory problems. Use when optimizing queries, fixing N+1 issues, improving response times, or when user mentions performance, slow, optimization, or Bullet gem.

Review Score

81%

Validation Score

12/16

Implementation Score

65%

Activation Score

100%

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

MethodUse When
includesMost cases (Rails chooses best strategy)
preloadForcing separate queries, large datasets
eager_loadFiltering on association, need single query
joinsOnly 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 ForExample
Foreign keysaccount_id, user_id
Columns in WHEREWHERE status = 'active'
Columns in ORDER BYORDER BY created_at DESC
Columns in JOINJOIN ON events.venue_id
Unique constraintsemail, 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

ProblemSolution
N+1 on belongs_toincludes(:association)
N+1 on has_manyincludes(:association)
Slow COUNTAdd counter_cache
Loading all columnsUse select or pluck
Large dataset iterationUse find_each
Missing index on FKAdd index on *_id columns
Slow WHERE clauseAdd index on filtered column
Loading unused associationsRemove from includes