Ruby Performance
When to Use
Use this skill when:
- User asks how to diagnose slow response times, high memory usage, or GC pressure in a Ruby or Ruby on Rails application
- User wants to profile a Ruby application and interpret results from tools like rack-mini-profiler, ruby-prof, stackprof, or derailed_benchmarks
- User needs to optimize specific Ruby constructs -- string allocation, object creation, ActiveRecord queries, or background job throughput
- User is designing a new Ruby service and wants to make performance-conscious architecture decisions upfront
- User needs to tune MRI (CRuby) runtime settings -- GC parameters, thread counts, Ractors, or GVL-aware concurrency patterns
- User is diagnosing memory bloat, memory leaks, or retained object accumulation across requests
- User wants to benchmark competing implementations and interpret results using benchmark-ips or benchmark/memory
- User is experiencing Sidekiq, Delayed Job, or Resque throughput problems and needs queue optimization guidance
Do NOT use this skill when:
- User needs help with JRuby or TruffleRuby platform-specific tuning -- those runtimes have different GC models, threading, and JVM/Graal-specific considerations
- User is asking about Rails architecture decisions unrelated to performance (authentication, authorization, API design) -- use a Rails architecture skill instead
- User needs database schema design or query optimization as the primary concern -- use a SQL/database optimization skill; this skill covers the Ruby-side query patterns (N+1, eager loading) but not index design
- User needs frontend performance (JavaScript bundle size, browser rendering, CSS) -- use a frontend performance skill
- User is asking about infrastructure scaling (horizontal pod autoscaling, CDN configuration, load balancing) -- performance gains at the Ruby layer should be established first
- User needs help with Ruby syntax, language fundamentals, or learning Ruby from scratch
- User is debugging a correctness bug rather than a performance problem -- do not conflate the two
Process
1. Establish a Baseline with Profiling Before Touching Code
Never optimize without measurement. Guessing at bottlenecks in Ruby code is almost always wrong.
- For request-level profiling in Rails: Add
rack-mini-profilerto the Gemfile (development + production if needed). It overlays per-request timings, SQL queries, and partial render times directly in the browser. Enable flamegraph support withstackprofas the backend:require 'rack-mini-profiler'; require 'flamegraph'. - For CPU profiling: Use
stackprofin wall-clock mode (mode: :wall) for I/O-bound code ormode: :cpufor CPU-bound code. Sample at 1000 Hz (the default). Runstackprof --text tmp/stackprof.dumpto get a flat caller/callee breakdown. Flamegraphs (SVG output) reveal the full call stack distribution. - For memory profiling: Use
memory_profilergem. Wrap a block withMemoryProfiler.report { ... }and call.pretty_print. Focus on "Total allocated" (objects and bytes), "Total retained" (memory not freed), and the top allocation sites by gem and file. - For bulk memory regression testing in Rails: Use
derailed_benchmarks. Runbundle exec derailed exec perf:memto measure memory per request andperf:objectsto count allocated objects. Establish a baseline number before any change and compare after. - For micro-benchmarks: Use
benchmark-ips(iterations per second) rather than the stdlibBenchmarkmodule. It handles warmup automatically (default 5 seconds warmup, 5 seconds measurement). Always run at least 3 rounds and check for variance. UseBenchmark.ips { |x| x.compare! }to rank alternatives. - Record baseline numbers in a comment or test:
# Baseline: 2.3ms per request, 12,400 objects allocatedso regressions are immediately visible.
2. Identify the Category of Bottleneck
Different bottlenecks require entirely different solutions. Misidentifying the category wastes days.
- CPU-bound: High
%userCPU, longstackprofframes in pure Ruby computation. Solutions involve algorithmic improvement, C extensions, or moving work to background jobs. - I/O-bound: High
%iowaitor longmode: :wallstackprof frames waiting on DB, HTTP, or filesystem. Solutions involve connection pooling, caching, async patterns, or query reduction. - Memory/GC-bound: High GC pause frequency visible in
GC::Profiler.enable; GC::Profiler.report. Ruby's GC pauses everything (stop-the-world). Solutions involve reducing object allocation, tuning GC parameters, or using frozen string literals. - Concurrency-bound: Threads blocked on the GVL (Global VM Lock) even with I/O work available. Solutions involve Ractors (Ruby 3+),
parallelgem, multi-process forking (Puma cluster mode), or async I/O withasyncgem. - ActiveRecord/ORM-bound: SQL query count explodes with dataset size (N+1) or individual queries are slow. Solutions involve eager loading, query optimization, or moving to raw SQL for hot paths.
Use this diagnosis checklist:
- Is GC time > 15% of wall time? -- GC-bound
- Is
pgormysql2adapter the top stackprof frame? -- DB I/O bound - Is object allocation count > 100,000 per request? -- likely allocation-bound
- Is CPU pegged at 100% on one core with multiple threads idle? -- GVL-bound
3. Address Object Allocation and GC Pressure
Ruby's MRI GC is generational and incremental since Ruby 2.1+ but still creates stop-the-world pauses during major GC. Every object allocated has a cost.
- Enable frozen string literals globally: Add
# frozen_string_literal: trueto every file, or configure it runtime-wide with--enable-frozen-string-literalin.ruby-version/RUBYOPT. Strings are the #1 source of unnecessary allocations in most Ruby codebases. This single change typically reduces allocations by 10-30%. - Use
String#<<(mutate) instead ofString#+(allocate):result = ""; parts.each { |p| result << p }allocates one string.parts.each { |p| result = result + p }allocates N strings. - Avoid creating intermediate arrays in hot loops:
array.map { }.select { }creates two arrays.array.filter_map { }(Ruby 2.7+) creates one.array.each_with_object([]) { }creates one. For large arrays this matters. - Use symbols over strings for hash keys:
{ name: "Alice" }vs{ "name" => "Alice" }. Symbols are interned (one object per value), strings are not (unless frozen). - Preallocate collections when size is known:
Array.new(1000)is faster than 1000Array#pushcalls that trigger internal resizing at capacities 4, 8, 16, 32... - Avoid
Object#dupandObject#clonein hot paths: They allocate a new object and copy instance variables. Cache or reuse objects where possible. - Tune GC parameters via environment variables for production:
RUBY_GC_HEAP_INIT_SLOTS=1000000-- pre-allocate heap slots to reduce early GC churnRUBY_GC_HEAP_GROWTH_FACTOR=1.25-- default is 1.8; lower values reduce memory bloat but increase GC frequencyRUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR=2.0-- controls when major GC triggers; raise to reduce major GC frequency at the cost of memoryRUBY_GC_MALLOC_LIMIT=67108864(64 MB) -- raise to defer minor GC on malloc-heavy code- Shopify's recommendation for Rails apps: set
MALLOC_ARENA_MAX=2(glibc env var) to dramatically reduce heap fragmentation on multi-threaded servers
4. Optimize ActiveRecord Query Patterns
ActiveRecord is convenient but generates N+1 queries and loads more data than necessary by default.
- Detect N+1 queries automatically: Add
bulletgem in development/test. ConfigureBullet.raise = truein test environment to fail tests on N+1. In development setBullet.alert = trueorBullet.rails_logger = true. - Use
includesfor associations accessed in views or serializers:Post.includes(:author, comments: :author).limit(20). Usepreloadto force two queries (never a JOIN) oreager_loadto force a LEFT OUTER JOIN when you need to filter on the association. - Use
selectto load only needed columns:User.select(:id, :email, :name)instead ofUser.all. Loading a largetextorjsonbcolumn for 1,000 records has measurable cost. - Use
pluckfor scalar values:User.where(active: true).pluck(:id)returns a plain Ruby array of integers -- no ActiveRecord objects instantiated. 10-100x faster than.map(&:id)on large result sets. - Use
find_eachorfind_in_batchesfor large datasets: NeverUser.all.eachon a table with > 10,000 rows.User.find_each(batch_size: 500)loads 500 at a time, instantiates, processes, then GCs them before the next batch. - Use
update_allanddelete_allfor bulk mutations:User.where(inactive: true).update_all(deleted_at: Time.current)is one SQL statement. Calling.each { |u| u.update!(deleted_at: ...) }is N SQL statements plus N object instantiations plus N ActiveRecord callbacks (which may be intentional -- know the trade-off). - Use database-side aggregation:
Order.where(user: user).sum(:total)is one SQLSUM(). Loading all orders and calling.sum(&:total)in Ruby loads all rows into memory. - Cache repeated queries with
Rails.cache: Usefetchwith a meaningful key and explicit TTL. For reference data (countries, product categories) that rarely changes, use an initializer with a class-level@@cacheorActiveSupport::Cache::MemoryStoreto avoid any serialization overhead.
5. Optimize Concurrency and Server Configuration
MRI Ruby has a GVL that prevents true parallel Ruby thread execution. Design around it.
- Puma thread count: The rule of thumb for DB-bound Rails apps is
min_threads = 5, max_threads = 5(keep min == max to avoid overhead). Each thread holds one DB connection. Setconfig/puma.rb:threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }. More threads beyond 5-10 yields diminishing returns under the GVL for CPU-bound work. - Puma cluster mode (workers): Each worker is a separate OS process with its own GVL.
workers = ENV.fetch("WEB_CONCURRENCY") { 2 }. With 2 workers × 5 threads = 10 concurrent requests. Memory cost: each worker is a full copy of the Rails process (typically 200-600 MB for a mid-size app). - Use
preload_app!in Puma:preload_app!forks workers after loading the app, enabling copy-on-write (CoW) memory sharing on Linux. Workers share read-only pages until they write. This can reduce memory by 30-50% in cluster mode. Must manually reconnect DB/Redis after fork:on_worker_boot { ActiveRecord::Base.establish_connection }. - For background jobs (Sidekiq): Sidekiq uses threads within a single process. Default concurrency is 10. Each Sidekiq thread holds one DB connection. Set
config/sidekiq.yml concurrency: 10and ensuredatabase.yml pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 10 } %>. For CPU-intensive jobs, lower concurrency to4-6to avoid GVL contention. - For genuinely CPU-parallel work in Ruby 3+: Ractors allow true parallelism but cannot share mutable state and cannot use most gems. Suitable for data processing pipelines with clean data boundaries. Not suitable for general Rails use today.
- For async I/O in Ruby 3+: The
asyncgem with Fiber-based concurrency enables thousands of concurrent I/O operations in a single thread. Suitable for HTTP clients, websockets, and bulk external API calls. Not a drop-in replacement for thread-based Puma.
6. Optimize Hot Code Paths with Ruby Idioms
After identifying a specific hot path via profiling, apply Ruby-specific optimizations.
- Method lookup: Ruby method lookup traverses the ancestor chain. Minimize deep inheritance for performance-critical code. Prefer composition over deep inheritance in hot paths.
attr_readervs custom getters:attr_reader :namegenerates optimized C-level bytecode. A manually writtendef name; @name; enddoes the same butattr_readeris marginally faster due to YARV optimization.- Avoid
method_missingin hot paths:method_missingis dispatched only after exhausting the full method lookup chain. Cache the result or usedefine_methodto create real methods. - Use
Comparable,Enumerablemodule inclusion wisely: IncludingEnumerableand definingeachgives you 50+ methods for free, but callinginclude?on an unsortedEnumerableis O(n). If you need fast membership testing, use aSet(hash-based, O(1) average). - Lazy enumerators for large/infinite sequences:
(1..Float::INFINITY).lazy.select { |n| n.odd? }.first(100)does not allocate the intermediate array. UseEnumerator::Lazywhen chaining operations on large collections where only a subset is consumed. - Memoization:
@result ||= expensive_computationis idiomatic Ruby memoization. For methods that can legitimately returnnilorfalse, usedefined?(@result) ? @result : @result = expensive_computationto avoid recomputing. - Struct vs OpenStruct:
Struct.new(:name, :age)generates a class with optimized attribute accessors and is nearly as fast as a plain Ruby class.OpenStruct(from ostruct library) uses a hash internally andmethod_missing-- it is 5-10x slower for attribute access. Never useOpenStructin hot paths. - Regexp compilation: Compile regexps once as constants (
PHONE_PATTERN = /\A\d{10}\z/.freeze) rather than creating them inline inside methods. Inline regexps in some Ruby versions create a new Regexp object per call.
7. Implement Caching at the Right Layer
Caching is the highest-leverage performance technique after query optimization, but cache invalidation is a source of bugs.
- Fragment caching in Rails views:
cache(["v1", post]) { render partial: "post", locals: { post: post } }. The cache key auto-expires whenpost.updated_atchanges. Use Russian doll caching for nested partials. - Low-level Rails.cache:
Rails.cache.fetch("user:#{id}:stats", expires_in: 15.minutes) { compute_stats(id) }. Use Redis withredis-rborredis-clientgem as the cache store in production. Configureconfig.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"], expires_in: 1.hour }. - Memoize at the request level with instance variables: A controller or service object that calls the same expensive method multiple times in one request should memoize with
@result ||=. This is zero-overhead and requires no external cache. - HTTP caching with ETags and Last-Modified:
fresh_when(etag: @post, last_modified: @post.updated_at)in Rails controllers returns 304 Not Modified and serves no response body if the client's cache is current. Eliminates view rendering and DB reads for cacheable resources. - Counter caches:
belongs_to :post, counter_cache: truemaintains acomments_countcolumn onpostsupdated by DB trigger-equivalent callbacks. Replacespost.comments.count(SQL COUNT) withpost.comments_count(integer read).
8. Validate with Benchmarks and Regression Prevention
Performance gains must be measured and protected from regression.
- Write benchmark scripts for hot paths: Keep a
benchmarks/directory withbenchmark-ipsscripts. Run before and after optimizations. Commit the results in a comment or separate.mdfile. - Add performance tests to CI with
rspec-benchmark: Assert response time percentiles (expect { call }.to perform_under(50).ms.warmup(10).times.sample(30).times) or allocation counts (expect { call }.to perform_allocation(500).objects). - Use
derailed_benchmarksin CI:bundle exec derailed exec perf:mem_over_timedetects memory leaks by running 1,000+ requests and plotting heap growth. - Track key metrics in production APM: NewRelic, Datadog APM, or Scout APM provide per-endpoint p50/p95/p99 response times, throughput, error rates, and GC metrics. Set alert thresholds at p95 > 500ms for user-facing endpoints.
- Profile before and after every significant change: Do not trust intuition. A "clean" refactor that introduces an N+1 query can turn a 10ms endpoint into a 500ms one.
Output Format
## Ruby Performance Analysis Report
### Baseline Measurements
| Metric | Value | Tool Used | Threshold |
|-------------------------|-------------|---------------------|-----------|
| p50 response time | X ms | rack-mini-profiler | < 100ms |
| p95 response time | X ms | APM / benchmark-ips | < 300ms |
| Objects allocated/req | X objects | memory_profiler | < 50,000 |
| Memory per request | X KB | derailed_benchmarks | < 2 MB |
| SQL queries per request | X queries | rack-mini-profiler | < 10 |
| GC time % of wall time | X% | GC::Profiler | < 10% |
### Bottleneck Diagnosis
**Category:** [CPU-bound | I/O-bound | Memory/GC-bound | Concurrency-bound | ORM-bound]
**Evidence:** [stackprof top frame | memory_profiler top allocations | N+1 query trace]
**Root Cause:** [specific finding, e.g., "User.all loading 40,000 rows per request"]
### Recommendations (Prioritized by Impact/Effort)
| Priority | Technique | Expected Gain | Effort | Risk |
|----------|----------------------------------|------------------------|--------|------|
| P1 | [e.g., Add includes(:author)] | -80% query count | Low | Low |
| P2 | [e.g., frozen_string_literal] | -15% allocations | Low | Low |
| P3 | [e.g., Redis fragment caching] | -60% render time | Medium | Med |
| P4 | [e.g., Puma preload_app!] | -30% memory (CoW) | Low | Low |
### Implementation
#### [Technique Name]
**Problem:** [specific observation from profiling]
**Solution:**
```ruby
# Before (problematic code with measurement)
# 2,400ms, 180 SQL queries
posts = Post.all
posts.each { |p| puts p.author.name }
# After (optimized code with measurement)
# 45ms, 2 SQL queries
posts = Post.includes(:author)
posts.each { |p| puts p.author.name }
Verification: [benchmark command and expected output]
GC / Runtime Configuration (if applicable)
# Production environment variables
RUBY_GC_HEAP_INIT_SLOTS=1000000
RUBY_GC_HEAP_GROWTH_FACTOR=1.25
RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR=2.0
MALLOC_ARENA_MAX=2
RAILS_MAX_THREADS=5
WEB_CONCURRENCY=2
Validation Results
| Metric | Before | After | Improvement |
|---|---|---|---|
| p50 response time | X ms | Y ms | Z% |
| Objects allocated/req | X | Y | Z% |
| SQL queries per request | X | Y | Z% |
| Memory per request | X KB | Y KB | Z% |
Regression Prevention
- benchmark-ips script added to
benchmarks/ - rspec-benchmark assertion added for this endpoint
- APM alert threshold configured (p95 < Xms)
- bullet gem enabled in test environment
---
## Rules
1. **Never optimize without profiling data.** Premature optimization based on intuition is the leading cause of wasted engineering time and introduced complexity. Every optimization must cite a specific profiler output (stackprof frame, memory_profiler allocation site, or SQL query trace) that justifies it.
2. **frozen_string_literal is always the first optimization applied.** It is zero-risk for most code, requires one line per file, and consistently reduces allocations by 10-30%. Any performance work that does not include this is incomplete.
3. **Distinguish `includes` vs `preload` vs `eager_load`.** `includes` lets Rails decide between the two; it chooses `preload` (two queries) by default but switches to `eager_load` (one JOIN) when you filter on the association in a `where`. Using `eager_load` naively on a `has_many` with many records creates a Cartesian product that can be slower. Profile both.
4. **Never use `pluck` when you need ActiveRecord objects downstream.** `pluck` skips instantiation, which means no callbacks, no virtual attributes, no `after_find`. It is only appropriate when you need raw scalar data.
5. **GC parameter tuning must be validated under production-like load.** Increasing `RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR` reduces GC frequency but increases memory. This trade-off is acceptable on a memory-constrained Heroku dyno only if you have headroom. Always load test after changing GC parameters with `wrk` or `ab` at realistic concurrency levels.
6. **Never set Puma thread count above the database connection pool size.** Every Puma thread holds one connection while active. If `max_threads=10` and `pool=5`, the 6th concurrent thread blocks waiting for a connection, causing request queuing. These two numbers must be equal.
7. **`find_each` is mandatory for any query that could return more than 1,000 rows.** Loading 10,000 ActiveRecord objects at once in a job or rake task will spike memory to several GB and trigger major GC. This is non-negotiable for data migration scripts and batch processors.
8. **Cache invalidation must be explicit and tested.** Every cache entry must have either a time-based TTL, a version key tied to a model's `updated_at`, or a manual invalidation call. Cached data with no invalidation strategy causes correctness bugs that are harder to debug than the original performance problem.
9. **Measure memory in addition to speed.** A code change that halves response time but doubles memory usage will cause OOM kills on containers and increase infrastructure cost. Both dimensions must be measured before declaring an optimization successful.
10. **Ractors and async/Fiber concurrency are not production-ready for general Rails use as of Ruby 3.3.** Most gems are not Ractor-safe (they use shared mutable state). Recommending Ractors as a general concurrency solution for a Rails app is incorrect. Limit Ractor recommendations to isolated data-processing use cases with explicit acknowledgment of gem compatibility requirements.
---
## Edge Cases
**Memory leak in long-running processes (Sidekiq workers, Puma workers):**
Ruby memory leaks most commonly come from: (1) objects retained in class-level constants or global variables that grow over time, (2) event listener callbacks that are never deregistered, (3) growing caches with no eviction policy. Use `ObjectSpace.each_object(ClassName).count` to count live instances of suspect classes. In production, use `rbtrace` gem to inspect object counts in a running process without restart: `bundle exec rbtrace -p PID -e 'ObjectSpace.each_object(String).count'`. If Sidekiq workers grow beyond a threshold (e.g., 512 MB), configure `SIDEKIQ_MEMORY_KILLER_MAX_RSS=512000` to restart workers that exceed the limit.
**N+1 queries hidden in serializers (ActiveModel::Serializers or fast_jsonapi):**
N+1 queries in serializers are not detected by simple controller-level analysis because the association is accessed during JSON rendering, which happens after the controller action returns. `bullet` catches these only if configured to monitor all SQL. Add `Bullet.raise = true` to test env and write request specs that call the serializer endpoint. Use `jsonapi-serializer` (formerly `fast_jsonapi`) with explicit `preload` in the controller action -- the serializer itself cannot trigger eager loading retroactively.
**String concatenation inside tight loops (log formatting, CSV generation):**
When building large strings iteratively (generating CSV rows, building SQL strings manually, constructing large reports), use `StringIO` instead of repeated concatenation: `require 'stringio'; io = StringIO.new; loop { io << row }; io.string`. For CSV generation of more than 10,000 rows, stream with `response.stream` in Rails rather than building the entire string in memory.
**Baseline drift in multi-tenant applications:**
In SaaS applications where tenants have vastly different data volumes, a performance benchmark run against a test tenant with 100 records will not reveal problems that appear only for enterprise tenants with 100,000 records. Always benchmark with a dataset representative of the largest 5% of tenants. Use `explain analyze` (PostgreSQL) or query plan analysis to confirm that query plans use indexes and do not fall back to sequential scans as data volume grows.
**ActiveSupport::Notifications and instrumentation overhead:**
Rails uses `ActiveSupport::Notifications` for instrumentation. In applications with many subscribers (custom metrics, APM agents, audit logging) on high-throughput endpoints (> 500 req/s), the notification pub-sub overhead can be measurable. Profile with `stackprof` and check for `ActiveSupport::Notifications::Fanout` in hot frames. Consider using `ActiveSupport::Notifications.unsubscribe` to remove unused subscribers, or replace custom subscribers with direct instrumentation.
**`respond_to?` and `send` in hot paths:**
Dynamic dispatch via `send(:method_name)` bypasses Ruby's inline method cache. `respond_to?` triggers a method lookup on every call. In a tight loop over 100,000 objects, this is measurable. Replace with explicit conditionals or duck-typed interfaces defined with `define_method` at class load time.
**Puma restarts resetting warm caches:**
When deploying a new version of a Rails app, Puma workers restart, clearing all in-process memoization (`@result ||=`) and `ActiveSupport::Cache::MemoryStore` entries. The first requests after deploy are "cold" and may be significantly slower. Mitigate by: (1) using a shared external cache (Redis) that persists across deploys, (2) implementing a cache warming rake task that runs pre-deploy, or (3) using rolling restarts in Puma cluster mode so at least one warm worker is always serving.
---
## Example
**Input:** "Our Rails 7 app's `GET /api/posts` endpoint takes 800ms at p95. It returns a list of blog posts with author names and comment counts. We have 15,000 posts in the database. How do I diagnose and fix this?"
**Output:**
## Ruby Performance Analysis Report
### Step 1: Instrument and Establish Baseline
Add to `Gemfile`:
```ruby
gem 'rack-mini-profiler', require: false
gem 'memory_profiler'
gem 'stackprof'
gem 'bullet', group: :development
Enable rack-mini-profiler for this endpoint in config/initializers/profiler.rb:
if Rails.env.development? || Rails.env.staging?
require 'rack-mini-profiler'
Rack::MiniProfiler.config.authorization_mode = :allow_authorized
end
Enable bullet in config/environments/development.rb:
config.after_initialize do
Bullet.enable = true
Bullet.rails_logger = true
Bullet.raise = true # fail on N+1 in tests
end
Make one request to GET /api/posts. rack-mini-profiler overlay reveals:
Baseline Measurements
| Metric | Value | Tool Used | Threshold |
|---|---|---|---|
| p95 response time | 800ms | rack-mini-profiler | < 300ms |
| Objects allocated/req | 284,000 | memory_profiler | < 50,000 |
| Memory per request | 18.4 MB | derailed_benchmarks | < 2 MB |
| SQL queries per request | 15,003 | rack-mini-profiler | < 10 |
| GC time % of wall time | 22% | GC::Profiler | < 10% |
Bottleneck Diagnosis
Category: ORM-bound (N+1 queries) + Memory/GC-bound (excessive object allocation)
Evidence: 15,003 SQL queries = 1 query for posts + 15,000 queries for authors (one per post) + 1 query for... wait, comment counts are coming from a .count call per post. Bullet log confirms: N+1 Query detected Post => [:author] and Counter cache not used for Post#comments.
Root Cause #1: Post.all loads all 15,000 posts with no pagination, no column selection
Root Cause #2: post.author.name triggers N+1 (15,000 individual author queries)
Root Cause #3: post.comments.count triggers N+1 (15,000 COUNT queries)
Root Cause #4: Loading full Post objects including a large body:text column not needed in the list view
Problematic Controller Code (Before)
# app/controllers/api/posts_controller.rb
def index
@posts = Post.all # loads 15,000 rows including body:text column
render json: @posts.map do |post|
{
id: post.id,
title: post.title,
author: post.author.name, # N+1: 1 query per post
comment_count: post.comments.count # N+1: 1 COUNT per post
}
end
end
Recommendations (Prioritized by Impact/Effort)
| Priority | Technique | Expected Gain | Effort | Risk |
|---|---|---|---|---|
| P1 | Add pagination (kaminari/pagy) | -99% query count, data vol | Low | Low |
| P2 | includes(:author) to fix author N+1 |
-14,999 queries | Low | Low |
| P3 | counter_cache: true for comments |
-15,000 COUNT queries | Low | Med* |
| P4 | select to exclude body column |
-60% memory/request | Low | Low |
| P5 | frozen_string_literal | -10% allocations | Low | Low |
*Counter cache requires a migration and backfill
Implementation
P1: Add Pagination with pagy
# Gemfile
gem 'pagy'
# app/controllers/application_controller.rb
include Pagy::Backend
# app/controllers/api/posts_controller.rb
def index
@pagy, @posts = pagy(
Post
.select(:id, :title, :author_id, :comments_count, :created_at)
.includes(:author)
.order(created_at: :desc),
items: 25 # 25 posts per page, not 15,000
)
render json: {
posts: @posts.map { |post| serialize_post(post) },
meta: pagy_metadata(@pagy)
}
end
Pagination alone reduces this from 15,000 rows to 25 rows -- a 600x reduction in data volume.
P2 + P3: Fix N+1 Queries
# Migration to add counter cache
class AddCommentsCountToPosts < ActiveRecord::Migration[7.1]
def change
add_column :posts, :comments_count, :integer, default: 0, null: false
# Backfill existing counts
Post.find_each { |p| Post.reset_counters(p.id, :comments) }
end
end
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true # maintains posts.comments_count
end
P4: Select Only Needed Columns
# Never load the body:text column in the list view
Post.select(:id, :title, :author_id, :comments_count, :created_at)
# This excludes body, metadata, and any other heavy columns
P5: Frozen String Literals
Add to every file in the project:
# frozen_string_literal: true
Or add to .rubocop.yml to enforce:
Style/FrozenStringLiteralComment:
Enabled: true
EnforcedStyle: always
Final Optimized Controller
# frozen_string_literal: true
# app/controllers/api/posts_controller.rb
class Api::PostsController < ApplicationController
include Pagy::Backend
def index
@pagy, @posts = pagy(
Post
.select(:id, :title, :author_id, :comments_count, :created_at)
.includes(:author)
.order(created_at: :desc),
items: 25
)
render json: {
posts: @posts.map { |post| serialize_post(post) },
meta: pagy_metadata(@pagy)
}
end
private
def serialize_post(post)
{
id: post.id,
title: post.title,
author: post.author.name,
comment_count: post.comments_count # reads integer column, no query
}
end
end
GC / Runtime Configuration
# Production Dockerfile / deployment env vars
RUBY_GC_HEAP_INIT_SLOTS=500000
RUBY_GC_HEAP_GROWTH_FACTOR=1.25
RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR=2.0
RUBY_GC_MALLOC_LIMIT=67108864
MALLOC_ARENA_MAX=2
RAILS_MAX_THREADS=5
WEB_CONCURRENCY=2
Validation Results
After applying P1-P5:
| Metric | Before | After | Improvement |
|---|---|---|---|
| p95 response time | 800ms | 18ms | -97.8% |
| Objects allocated/req | 284,000 | 3,200 | -98.9% |
| Memory per request | 18.4 MB | 0.4 MB | -97.8% |
| SQL queries per request | 15,003 | 2 | -99.9% |
| GC time % of wall time | 22% | 1.2% | -94.5% |
Two SQL queries remain: one for the paginated posts (with author preloaded as a second query by includes), which is the correct minimum.
Regression Prevention
Add to spec/requests/api/posts_spec.rb:
require 'rails_helper'
require 'rspec-benchmark'
RSpec.describe 'GET /api/posts' do
include RSpec::Benchmark::Matchers
before { create_list(:post, 100, :with_author, :with_comments) }
it 'responds in under 50ms' do
expect { get '/api/posts' }.to perform_under(50).ms
.warmup(5).times
.sample(20).times
end
it 'executes at most 5 SQL queries' do
query_count = 0
counter = ->(*, **) { query_count += 1 }
ActiveSupport::Notifications.subscribed(counter, 'sql.active_record') do
get '/api/posts'
end
expect(query_count).to be <= 5
end
end
Configure APM alert: p95 response time alert threshold set to 50ms (3x our measured 18ms baseline) so we are alerted before user experience degrades.