DEV Community

yureki_lab
yureki_lab

Posted on

How I Hunted Down 23 N+1 Queries in a Rails App With Claude Code

TL;DR

Our Rails app's p95 response time had crept from 280 ms to 1.4 s over a year, and nobody could say why. I paired with Claude Code for four days, found 23 N+1 queries, fixed 19 of them, and got p95 back under 400 ms. The real win wasn't the fixes, though: it was making Claude prove every N+1 with a failing test before touching the code, because the first "fix everything with includes" pass made two endpoints slower.

The Problem

Here's the setup: a seven-year-old Rails monolith (Rails 7.1, Ruby 3.3, PostgreSQL 16), about 140 models, a B2B dashboard that customers live in all day. Nothing was on fire. It was just... slow. The kind of slow where support tickets say "the app feels sluggish" and nobody can reproduce anything specific.

When I pulled APM data, the pattern was obvious in hindsight:

  • p95 on the dashboard endpoint: 1.4 s (was ~280 ms a year earlier)
  • Average SQL queries per request on the top 10 endpoints: 187
  • Worst single request I could find: 2,341 queries to render one page

Nobody had written a "bad" query. Every one of those queries was a perfectly reasonable SELECT ... WHERE id = $1. The problem was that they ran inside loops, inside partials, inside serializers, inside helper methods that called other helper methods. Classic N+1, spread across hundreds of files.

I'd tried fixing this by hand twice before. Both times I got through three or four endpoints, got pulled onto feature work, and the gains evaporated as new code reintroduced the same patterns. This time I wanted two things:

  1. Find all of them, not just the ones I happened to notice.
  2. Make sure they stay fixed.

That second goal turned out to be the one that mattered.

How I Solved It

Step 1: Measure before touching anything

My first instinct was to open Claude Code and say "find N+1 queries in this app." I did exactly that, and it came back with a list of 60+ "suspicious" associations based on reading the code. Most were false positives: associations that looked like they'd loop, but were only ever called on a single record.

Static reading isn't enough for N+1s. You need runtime evidence. So I backed up and asked Claude to help me build a tiny query counter that logs per-request query counts with the call site that triggered them:

# config/initializers/query_counter.rb
module QueryCounter
  THRESHOLD = 50

  def self.track
    counts = Hash.new(0)
    sub = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
      next if payload[:name] == "SCHEMA" || payload[:cached]
      site = caller_locations.find { |l| l.path.include?("/app/") }
      counts["#{site&.path}:#{site&.lineno}"] += 1
    end
    yield
    counts
  ensure
    ActiveSupport::Notifications.unsubscribe(sub)
  end
end
Enter fullscreen mode Exit fullscreen mode

Then a around_action in development and staging that logs anything over the threshold, sorted by hottest call site. Running our existing request specs plus a 20-minute click-through of the dashboard produced a ranked list of every line in app/ that fired the same query more than ~10 times in one request.

That list had 23 entries. Not 60. Twenty-three real, reproducible N+1s, each with an exact file and line number.

πŸ’‘ This was the single highest-leverage thing I did all week. Claude is great at fixing a problem it can see. It's mediocre at guessing which problems are real.

Step 2: The mistake β€” "just add includes everywhere"

With the list in hand, I got greedy. I handed Claude all 23 call sites and said "fix these with eager loading."

It did. Fast. Every fix looked textbook:

# before
@projects = current_account.projects.active

# after
@projects = current_account.projects.active
  .includes(:owner, :tags, tasks: [:assignee, :comments])
Enter fullscreen mode Exit fullscreen mode

Tests passed. I deployed to staging. Two endpoints got slower. The project list went from 900 ms to 1.6 s.

What happened: tasks: [:assignee, :comments] eager-loaded every comment on every task on every project, for a page that only displayed a comment count. For our biggest customer that meant pulling ~80,000 comment rows into Ruby memory to call .size on them. We'd swapped 400 tiny queries for one enormous one plus a mountain of object allocation.

Claude wasn't wrong, exactly. It solved the problem I described ("eager-load these"), not the problem I had ("make this page fast"). That's on me.

Step 3: Failing test first, every single time

I reverted and switched to a strict loop. For each of the 23 call sites:

  1. Write a test that asserts a query budget for that endpoint, and confirm it fails.
  2. Only then, let Claude propose a fix.
  3. Re-run the test plus a quick timing check against a realistic data fixture.

The query-budget helper is tiny:

# spec/support/query_budget.rb
RSpec::Matchers.define :make_at_most do |expected|
  supports_block_expectations

  match do |block|
    @actual = 0
    counter = ->(*, payload) { @actual += 1 unless payload[:cached] || payload[:name] == "SCHEMA" }
    ActiveSupport::Notifications.subscribed(counter, "sql.active_record", &block)
    @actual <= expected
  end

  failure_message { "expected at most #{expected} queries, got #{@actual}" }
end
Enter fullscreen mode Exit fullscreen mode

And a typical spec:

it "renders the project list in a bounded number of queries" do
  create_list(:project, 25, :with_tasks, account: account)
  expect { get projects_path }.to make_at_most(12).queries
end
Enter fullscreen mode Exit fullscreen mode

The key detail: the test seeds 25 records, not 1. With one record, an N+1 is invisible. With 25, it explodes the budget immediately.

Once the failing test existed, the conversation with Claude changed completely. Instead of "add includes," I'd say: "This spec fails with 214 queries. Make it pass. The view only shows the comment count, not the comments." And it would reach for the right tool:

# counter cache instead of eager-loading 80k rows
class Comment < ApplicationRecord
  belongs_to :task, counter_cache: true
end
Enter fullscreen mode Exit fullscreen mode

Across the 23 call sites, the fixes broke down like this:

Fix Count
Targeted includes / preload 9
Counter cache column 4
Single aggregate query (group(...).count) 4
Moved logic out of a partial loop 2
Left alone (not worth it) 4

That last row matters. Four of the N+1s were on admin pages used by three people a week, with max N around 10. Fixing them meant a migration and a backfill. Not worth it. Claude actually flagged two of those itself when I asked "is this fix worth the complexity?", which I appreciated.

Step 4: Lock it in CI

The query-budget specs are now regular specs. They run on every PR. If someone adds a loop that lazy-loads an association on the dashboard, the build goes red with a message like:

Failure/Error: expect { get dashboard_path }.to make_at_most(15).queries
  expected at most 15 queries, got 63
Enter fullscreen mode Exit fullscreen mode

That's a far better feedback loop than "p95 crept up 40 ms this quarter, anyone know why?"

Here's the overall flow I ended up with:

flowchart LR
    A[Runtime query log] --> B[Ranked call sites]
    B --> C[Write failing budget spec]
    C --> D[Claude proposes fix]
    D --> E{Spec passes + timing OK?}
    E -- No --> D
    E -- Yes --> F[Commit spec + fix]
    F --> G[CI guards forever]

The results

After four days (roughly 26 hours of actual work):

  • Dashboard p95: 1.4 s β†’ 380 ms βœ…
  • Average queries per request (top 10 endpoints): 187 β†’ 14 βœ…
  • Worst request: 2,341 queries β†’ 31 βœ…
  • New query-budget specs: 19, all running in CI
  • Regressions caught by those specs in the first three weeks: 3 πŸš€

That last number is my favorite. Three PRs would have quietly reintroduced N+1s. Instead, they failed the build, and the authors fixed them before review.

Lessons Learned

1. Runtime data beats static analysis for performance bugs

Asking an AI agent to "find N+1 queries" by reading code gives you a list of suspects, not culprits. Give it a log of actual query counts per call site and it stops guessing. Measure first, then delegate.

2. Describe the outcome, not the technique

"Add eager loading here" gets you eager loading, even when it's the wrong call. "This page shows a count, make it under 12 queries" gets you a counter cache. The agent is only as good as the goal you hand it. If you name the technique, you've already made the decision for it.

3. A failing test is the best prompt you can write

A red spec with a concrete number ("got 214, expected ≀12") is unambiguous in a way no English sentence is. It also gives the agent a way to check its own work, so you're not the verification step. Every time I skipped this, I regretted it.

4. Seed realistic N in tests

One record hides every N+1. Twenty-five exposes them. If your factories default to creating a single child record, your performance tests are lying to you.

5. "Not worth fixing" is a valid outcome

I almost let Claude fix all 23 just because it could. But each fix has a cost: migrations, backfills, more code to maintain. Asking "is this worth it?" explicitly, and letting the agent argue both sides, saved me two pointless migrations.

What's Next

A few things I'm working on now:

  • Budget specs for background jobs. The same N+1 patterns show up in nightly report jobs, where they cost CPU instead of latency. Nobody notices until a job runs past its window.
  • Auto-generating budget specs. I'm experimenting with having Claude draft a query-budget spec for any new controller action in a PR, so the guardrail exists from day one instead of after the slowdown.
  • Memory budgets. Query count was the obvious metric. Allocated objects per request is the less obvious one, and the counter-cache episode taught me it matters just as much.

Wrap-up

If your Rails app has gotten mysteriously slow, I'd bet money it's N+1s, and I'd bet more that fixing them without tests means they'll be back in six months. Measure first, write the failing spec, then let your AI agent do the tedious part.

Tested with Claude Code v2.x, Rails 7.1, Ruby 3.3, PostgreSQL 16 (October 2026).

πŸ‘‰ Follow me on Dev.to for more build logs on shipping real code with AI coding agents. And if you've got a war story about an "optimization" that made things slower, drop it in the comments. I want to hear it.

Top comments (0)