home / skills / thibautbaissac / rails_ai_agents / performance-optimization
/skills/performance-optimization
This skill helps optimize Rails performance by detecting N+1 queries, indexing gaps, and memory issues to speed up responses.
npx playbooks add skill thibautbaissac/rails_ai_agents --skill performance-optimizationReview the files below or copy the command above to add this skill to your agents.
---
name: performance-optimization
description: 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.
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
---
# 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
```ruby
# Gemfile
group :development, :test do
gem 'bullet' # N+1 detection
gem 'rack-mini-profiler' # Request profiling
gem 'memory_profiler' # Memory analysis
end
```
## Bullet Configuration
```ruby
# 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
```ruby
# 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
```ruby
# 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)
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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?
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# Analyze query plan
Event.where(status: :active).explain
# Analyze with format
Event.where(status: :active).explain(:analyze)
```
### Logging Slow Queries
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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
```ruby
# 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` |
This skill identifies and fixes common Rails performance problems such as N+1 queries, slow SQL, and memory bloat. It provides practical fixes: eager loading patterns, indexing guidance, batching, and profiling integration with tools like Bullet and rack-mini-profiler. Use it to improve response times, reduce DB load, and lower memory usage in Rails 8 applications.
The skill inspects controller actions, ActiveRecord usage, and test suites to detect inefficient patterns (N+1, unnecessary selects, full-table loads). It suggests concrete code changes: includes/preload/eager_load, counter_cache migrations, selective select/pluck, batch processing, and appropriate indexes. It also recommends configuration and test hooks for Bullet, rack-mini-profiler, and memory_profiler and helps interpret EXPLAIN output and slow-query logging.
When should I use preload vs eager_load?
Use preload to force separate queries when datasets are large; use eager_load (LEFT JOIN) when you need to filter or sort on the association.
How do I detect missing indexes quickly?
Scan tables for *_id columns without indexes programmatically or review slow queries and EXPLAIN output; add single or composite indexes where WHERE, JOIN, or ORDER BY are frequent.