| name | performance-optimization |
| description | Performance optimization patterns for kw-app - N+1 query prevention, eager loading, caching strategies, and database optimization. |
| allowed-tools | Read, Write, Edit, Bash |
Performance Optimization (kw-app)
Overview
Common performance patterns for Rails applications:
- N+1 query detection and prevention
- Eager loading strategies
- Database indexing
- Caching (fragment, action, HTTP)
- Background job optimization
N+1 Query Prevention
What is N+1?
users = User.all
users.each do |user|
puts user.posts.count
end
users = User.includes(:posts)
users.each do |user|
puts user.posts.count
end
Detection
Bullet Gem (already in kw-app):
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.bullet_logger = true
Bullet.console = true
Bullet.rails_logger = true
end
Manual Detection:
ActiveRecord::Base.logger = Logger.new(STDOUT)
User.all.each { |u| u.posts.count }
Eager Loading Strategies
includes (Most Common)
users = User.includes(:posts, :comments)
When to use:
- Need to access association records
- Multiple associations to load
- Default choice for eager loading
preload (Force Separate Queries)
users = User.preload(:posts).where(posts: { published: true })
When to use:
- Want to guarantee separate queries
- Loading many associations
- Large datasets
eager_load (Force LEFT OUTER JOIN)
users = User.eager_load(:posts).where(posts: { published: true })
When to use:
- Need to filter on association columns
- Smaller datasets
- Need SQL-level joins
joins (Association Filter Only)
users = User.joins(:posts).where(posts: { published: true })
When to use:
- Only need to filter, not access associations
- Most efficient for filtering
Common N+1 Patterns
Pattern 1: Simple Association
@users = User.all
@users = User.includes(:profile)
Pattern 2: Nested Associations
@users = User.all
@users = User.includes(posts: :comments)
Pattern 3: Multiple Associations
@users = User.all
@users = User.includes(:profile, :posts, :settings)
Pattern 4: Polymorphic Associations
@comments = Comment.all
@comments = Comment.includes(:commentable)
Pattern 5: Counting Associations
@users = User.includes(:posts)
class User < ApplicationRecord
has_many :posts
end
class Post < ApplicationRecord
belongs_to :user, counter_cache: true
end
add_column :users, :posts_count, :integer, default: 0
Database Indexing
When to Add Index
add_index :posts, :user_id
add_index :users, :email
add_index :posts, :published_at
add_index :users, :email, unique: true
add_index :posts, [:user_id, :published_at]
add_index :posts, :created_at
Check Missing Indexes
ActiveRecord::Base.connection.tables.each do |table|
puts "Table: #{table}"
columns = ActiveRecord::Base.connection.columns(table)
foreign_keys = columns.select { |c| c.name.end_with?('_id') }
foreign_keys.each do |fk|
indexed = ActiveRecord::Base.connection.indexes(table).any? { |i| i.columns.include?(fk.name) }
puts " ⚠️ Missing index: #{fk.name}" unless indexed
end
end
Caching Strategies
Fragment Caching (View Level)
<!-- app/views/posts/show.html.erb -->
<% cache @post do %>
<h1><%= @post.title %></h1>
<p><%= @post.body %></p>
<% end %>
<!-- Cache key: posts/123-20240115120000000000/... -->
Auto-expires when:
@post.updated_at changes
- Template file changes
Collection Caching
<!-- app/views/posts/index.html.erb -->
<%= render partial: 'post', collection: @posts, cached: true %>
<!-- Each post cached separately -->
Russian Doll Caching
<!-- app/views/posts/show.html.erb -->
<% cache @post do %>
<h1><%= @post.title %></h1>
<% cache [@post, 'comments'] do %>
<% @post.comments.each do |comment| %>
<% cache comment do %>
<%= render comment %>
<% end %>
<% end %>
<% end %>
<% end %>
touch: true to expire parent:
class Comment < ApplicationRecord
belongs_to :post, touch: true
end
Low-Level Caching
class User
def expensive_calculation
Rails.cache.fetch("users/#{id}/calculation", expires_in: 1.hour) do
perform_complex_calculation
end
end
end
Cache Invalidation
Rails.cache.delete("users/#{user.id}/calculation")
Rails.cache.delete_matched("users/*/calculation")
Rails.cache.clear
Query Optimization
Select Only Needed Columns
users = User.all
users = User.select(:id, :email, :name)
Use pluck for Simple Attributes
user_ids = User.all.map(&:id)
user_ids = User.pluck(:id)
emails_and_names = User.pluck(:email, :name)
Batch Processing
User.all.each do |user|
user.process
end
User.find_each do |user|
user.process
end
User.find_each(batch_size: 500) do |user|
user.process
end
User.where(active: true).find_each do |user|
user.process
end
exists? vs any?/present?
if User.where(email: email).any?
end
if User.where(email: email).exists?
end
Background Job Optimization
Batch Jobs Instead of Individual
users.each do |user|
SendEmailJob.perform_later(user.id)
end
SendBatchEmailJob.perform_later(users.pluck(:id))
class SendBatchEmailJob < ApplicationJob
def perform(user_ids)
users = User.where(id: user_ids).includes(:profile)
users.find_each do |user|
UserMailer.notification(user).deliver_now
end
end
end
Avoid Serializing Large Objects
NotificationJob.perform_later(user)
NotificationJob.perform_later(user.id)
def perform(user_id)
user = User.find_by(id: user_id)
return unless user
end
Monitoring Performance
In Development
config.active_record.verbose_query_logs = true
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
end
In Production
Check slow queries:
SELECT query, calls, total_time, mean_time
FROM pg_stat_statements
ORDER BY mean_time DESC
LIMIT 10;
Rails logs:
grep "Completed 200" log/production.log | awk '{print $NF}' | sort -n | tail
Quick Checklist
Before deploying, check:
Common Mistakes
❌ Mistake 1: Over-eager loading
users = User.includes(:posts, :comments, :profile, :settings, :notifications)
users = User.includes(:posts)
❌ Mistake 2: Caching without expiration
Rails.cache.fetch("stats") do
calculate_stats
end
Rails.cache.fetch("stats", expires_in: 1.hour) do
calculate_stats
end
❌ Mistake 3: N+1 in JSON rendering
class UserSerializer
def posts
object.posts.map { |p| PostSerializer.new(p) }
end
end
@users = User.all
@users = User.includes(:posts)
Tools & Resources
- Bullet: N+1 detection
- rack-mini-profiler: Per-request profiling
- pg_stat_statements: PostgreSQL query analysis
- New Relic/Scout: Production monitoring
- kw-app Monitoring: Check Sidekiq web UI at
/sidekiq
Quick Reference
| Problem | Solution |
|---|
| N+1 queries | includes, preload, eager_load |
| Slow counts | Counter cache |
| Large datasets | find_each, find_in_batches |
| Expensive views | Fragment caching |
| Slow calculations | Low-level caching |
| Missing indexes | add_index |
| Full column load | select, pluck |
Version: 2.0
Last Updated: 2024-01
Maintained By: kw-app team