Hunting N+1 Queries Systematically: A Three-Layer Defense
Every Rails team eventually has the N+1 conversation, and it almost always starts in the wrong place. Someone profiles a slow page, finds a loop issuing fifty queries, adds an includes, and declares victory. Two weeks later a different page does the same thing. The problem is not the individual N+1 — it is that the team has no system for catching them. They are playing whack-a-mole against a defect class that the framework actively makes easy to introduce.
The good news is that the Ruby ecosystem gives you three distinct tools for this, and the mistake most teams make is treating them as alternatives. They are not. Bullet, Prosopite, and strict_loading each catch N+1s at a different point in the lifecycle: while you are writing code, while your test suite runs, and at the boundary of a specific query you care about. Use all three and you build a defense in depth that a single tool cannot match.
Layer 1: Bullet, for the development loop
Bullet is the oldest and best-known of the three. It hooks into Active Record during development, watches which associations get loaded per request, and warns you when it sees the signature of an N+1: a parent collection loaded, then the same association fetched once per element.
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.bullet_logger = true
Bullet.rails_logger = true
Bullet.add_footer = true # inline warning in the browser
end
Bullet’s strength is immediacy: you see the warning in the page footer while you are clicking through the feature you just wrote. Its weakness is exactly the same thing. It only sees the requests you happen to exercise, with the data you happen to have in your dev database. An N+1 that only fires for a record in an archived state, or a feature-flagged branch you did not click, is invisible to Bullet. It is a fine first line, a poor last one.
One practical note: Bullet also warns about unused eager loading — an includes you added for an association you never actually touch. Those warnings are worth heeding too. Over-eager loading wastes memory and a query just as surely as under-loading wastes round-trips.
Layer 2: Prosopite, for the test suite
Prosopite was built to fix Bullet’s coverage problem. Instead of watching the development server, it watches your test suite — which, in a mature app, exercises far more of the request space than any human clicking around will. It detects N+1s by fingerprinting queries: when it sees the same query structure fired multiple times with only the bound parameters changing, within a single scan window, it flags it.
The cleanest way to run it is per-example, raising on detection so the offending spec fails loudly:
# spec/rails_helper.rb
RSpec.configure do |config|
config.before(:each, n_plus_one: true) do
Prosopite.scan
end
config.after(:each, n_plus_one: true) do
Prosopite.finish
end
end
Prosopite.raise = true
Then tag the request and serializer specs that render collections:
it "lists orders without N+1", n_plus_one: true do
create_list(:order, 5, customer: customer)
get orders_path
expect(response).to have_http_status(:ok)
end
The detail that catches people: you must create more than one record. With a single record there is no second iteration, so there is no N+1 to detect — the spec passes and lies to you. create_list with at least two (ideally three or more) is what makes the missing includes visible. This is the single most common reason a Prosopite suite gives false confidence.
The other detail: Prosopite needs to run inside a transaction-aware setup and can produce false positives for legitimately distinct queries that merely look alike. It supports a pause/resume and an allow-list for the genuine exceptions. Tune those rather than disabling the whole check — a noisy guard gets ignored, and an ignored guard is worse than no guard.
Layer 3: strict_loading, for the records you cannot afford to get wrong
Bullet and Prosopite are detectors — they tell you after the fact. strict_loading, built into Rails since 6.1, is a preventer. When you mark an association or a record as strict-loading, any attempt to lazily load a not-yet-loaded association raises immediately. There is no “it slipped through” — the code physically cannot issue the extra query.
You can scope it as tightly or as broadly as you like. On a single query:
orders = Order.strict_loading.includes(:customer, :line_items)
# order.shipment => raises ActiveRecord::StrictLoadingViolationError
On a model, so every instance is strict by default:
class Order < ApplicationRecord
self.strict_loading_by_default = true
end
Or globally, in an environment config, with config.active_record.strict_loading_by_default = true. Going global on an existing codebase is a large commitment — it will surface every lazy load you have, all at once — but on a new service it is one of the highest-leverage settings you can flip. It changes N+1 from a runtime performance bug into a development-time exception. You cannot ship one without the test that exercises that path going red.
The right mental model: strict_loading is to query loading what NOT NULL is to columns. It moves a class of mistake from “discovered in production” to “impossible to express.” Use it aggressively on hot read paths — list endpoints, serializers, exports — where the cost of a missed N+1 scales with customer data size.
Putting the three together
The layers map onto the lifecycle cleanly:
| Tool | Stage | Catches | Mode |
|---|---|---|---|
| Bullet | Development | What you click through | Warn |
| Prosopite | CI / test suite | Everything the suite covers | Detect & fail |
| strict_loading | Runtime / dev | Lazy loads on guarded paths | Prevent |
A reasonable rollout on an existing app looks like: turn Bullet on for the team today (cheap, immediate, opt-in by nature). Add Prosopite to the request and serializer specs over a sprint, fixing the failures it surfaces as you go. Then introduce strict_loading on your two or three hottest read endpoints, where it pays for itself fastest, and expand outward. On a greenfield service, flip strict_loading_by_default on day one and never accumulate the debt at all.
The fix is almost always the same — and so is the trap
Ninety percent of N+1 fixes are an includes (or preload / eager_load when you need control over join vs. separate-query strategy). The trap is the COUNT N+1, which the eager-loading reflex does not fix: collection.items.count in a loop issues a query per parent even when the parent collection is preloaded, because .count always hits the database. Use a counter_cache, or preload the association and call .size / .length on the loaded records. Knowing that count, size, and length behave differently on an association is half the battle.
The takeaway
N+1 is not a bug you fix once; it is a defect class you build a process around. Bullet catches it while you write, Prosopite catches it in CI across the whole request space, and strict_loading makes it impossible on the paths you care most about. Teams that wire all three in stop having the N+1 conversation, because the framework stops letting them ship the problem.
We set this exact pipeline up on Rails performance engagements. If your app is “slow” in the vague way mature apps get slow, this is usually where we start.