CtrlK
BlogDocsLog inGet started
Tessl Logo

performance-optimization

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.

Install with Tessl CLI

npx tessl i github:fernandezbaptiste/rails_ai_agents --skill performance-optimization
What are skills?

90

Does it follow best practices?

Validation for skill structure

SKILL.md
Review
Evals

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
Repository
fernandezbaptiste/rails_ai_agents
Last updated
Created

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.