Your API Is Slow and It's Probably N+1 Queries
Your list endpoint runs 51 queries where 2 would do. What an N+1 actually is, why it survives code review, and how to fix it with eager loading.
Someone reports that a page takes four seconds to load. You open the controller expecting to find something horrifying — a nested loop, a missing index, a synchronous HTTP call to a third-party API.
Instead you find eight lines of perfectly ordinary code. It's clean. It's readable. You'd approve it in a review without a second thought.
That's the thing about N+1 queries. They don't look like bugs. They look like good code.
What an N+1 actually is
Say you have a /posts endpoint that returns the 50 most recent posts, each with its author's name.
public function index()
{
$posts = Post::latest()->take(50)->get();
return PostResource::collection($posts);
}
And the resource:
public function toArray($request)
{
return [
'id' => $this->id,
'title' => $this->title,
'author' => $this->author->name, // ← here
];
}
That single line is the whole problem.
Post::latest()->take(50)->get() is one query. It fetches 50 posts. Fine.
Then the resource runs once per post. Each time it hits $this->author, Eloquent notices the relation hasn't been loaded and quietly goes and fetches it. That's 50 more queries, one at a time, each one a round trip to the database.
select * from posts order by created_at desc limit 50
select * from users where id = 7 limit 1
select * from users where id = 3 limit 1
select * from users where id = 7 limit 1 -- yes, again
select * from users where id = 12 limit 1
... 46 more
1 + 50 = 51 queries for a page that needs two. And notice it re-fetches user 7 twice, because each lazy load is independent — Eloquent has no idea it already has that row in memory.
That's the N+1: one query to get the list, then N queries to get something related for each item.
Why it survives every code review
Here's what makes this bug different from most bugs: there is nothing wrong with the code.
$post->author->name is exactly how you're supposed to use an ORM. That's the whole selling point — relations feel like object properties. The abstraction is doing its job beautifully, right up until the moment it isn't.
There's no error. No warning. No lint rule fires. The tests pass, because your test fixture has three posts.
And that's the second half of it: N is small in development and large in production. You seed five records locally, the endpoint returns in 40ms, and you ship. Six months later there are 5,000 records, someone bumps the page size to 100, and the same code is making 100 sequential round trips per request. At 2ms per round trip that's 200ms of pure latency doing nothing but waiting.
The bug didn't appear. It was always there. Your data just grew into it.
Multiply by concurrent users and you get the classic symptom: the app is fine at 10 users and falls over at 200, and your database CPU graph looks like a wall.
The fix: tell the ORM what you need up front
Eager loading. You say in advance which relations you're going to touch, and the ORM fetches them all in one extra query using a WHERE IN.
$posts = Post::with('author')
->latest()
->take(50)
->get();
Two queries. Total.
select * from posts order by created_at desc limit 50
select * from users where id in (7, 3, 12, 41, 5, ...)
Same output, same resource code, 51 queries down to 2. You can load several relations at once:
Post::with(['author', 'comments', 'tags'])->get();
That's four queries — one for posts, one each for authors, comments and tags — regardless of whether you're returning 50 posts or 5,000.
Every mature ORM has this. Different spelling, same idea:
| Stack | Eager load | Nested |
|---|---|---|
| Laravel / Eloquent | ->with('author') |
->with('comments.user') |
| Rails / ActiveRecord | .includes(:author) |
.includes(comments: :user) |
| Prisma | include: { author: true } |
nested include |
| TypeORM | relations: ['author'] |
relations: ['comments.user'] |
| Django | select_related / prefetch_related |
prefetch_related('comments__user') |
If you want it enforced rather than remembered, Laravel lets you fail loudly the moment anything lazy-loads:
// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());
In dev and test that throws a LazyLoadingViolationException the instant you touch an unloaded relation. In production it stays off, so you get a slow page rather than a 500. I turn this on in every new project now — it turns an invisible performance bug into a loud test failure.
The nested trap
This is the one that got me the most times. You fix an N+1, feel good about it, and immediately create another one a level down.
$posts = Post::with('comments')->get(); // fixed the comments N+1
foreach ($posts as $post) {
foreach ($post->comments as $comment) {
echo $comment->user->name; // ← new N+1, one per comment
}
}
comments is eager loaded. comments.user is not. So now instead of one query per post, you get one query per comment — which is usually a much bigger number than the one you started with. Congratulations, you made it worse.
The fix is dot notation:
Post::with('comments.user')->get();
Three queries: posts, comments for those posts, users for those comments. Rails: includes(comments: :user).
The rule I use: every relation you touch anywhere in the render path needs to appear in the with(). Not just the ones in the controller — the ones in your resource classes, your Blade partials, your accessors. Especially accessors, because a computed attribute quietly touching a relation is the sneakiest version of this bug there is.
Over-fetching: the trap on the other side
Eager loading is not free, and "just add with() to everything" is its own bug.
with('comments') on 50 posts that each have 400 comments pulls 20,000 rows into PHP memory to render a page that shows a comment count. You traded 51 fast queries for 2 queries and an out-of-memory error. That's not a win.
Three things I reach for instead:
If you only need a number, use a count. No rows loaded at all.
Post::withCount('comments')->get();
// then: $post->comments_count
If you only need a few columns, ask for a few columns. Include the foreign key, or the relation silently breaks — Eloquent needs user_id on comments to match them back to posts.
Post::with([
'author:id,name',
'comments:id,post_id,body',
])->get();
If you only need a slice, constrain the relation.
Post::with(['comments' => fn ($q) => $q->latest()->limit(3)])->get();
And the honest fourth option: sometimes the right answer isn't eager loading at all, it's a join with an explicit select, or a dedicated read query that returns exactly the shape the endpoint needs. ORMs are excellent until you're fighting them, and a hand-written query for one hot endpoint is not a failure of engineering.
How to actually catch these
You will not spot N+1s by reading code. I've tried. The code looks fine — that's the entire premise of this post. You catch them by counting.
Log queries in dev. Cheap and immediate:
DB::listen(fn ($q) => logger()->debug($q->sql, ['time' => $q->time]));
Fifty near-identical lines in your log is the signature. Once you've seen it, you recognise it instantly.
Use the tools. Laravel Debugbar puts a live query count in the corner of every page. Rails has Bullet, which pops a warning telling you exactly which includes you forgot. Prisma logs queries with log: ['query']. Django Debug Toolbar does the same job. All of them make the invisible visible.
Assert query counts in tests. This is the one that actually stops regressions, because it fails in CI rather than in production:
public function test_posts_index_stays_within_query_budget(): void
{
Post::factory()->count(50)->create();
$queries = 0;
DB::listen(function () use (&$queries) { $queries++; });
$this->getJson('/api/posts')->assertOk();
$this->assertLessThan(6, $queries);
}
Seed 50 records, not 3. A test with three fixtures passes happily at 4 queries whether the code is O(1) or O(N). The whole point is to make N big enough that a regression is obvious.
Then set a budget and hold the line: no endpoint may exceed N queries. Pick a number — 10 is a reasonable starting point for most read endpoints — and put it in CI. Budgets work because they're boring and automatic, and nobody has to remember anything.
The honest caveat
Not every query inside a loop is a bug.
If you're loading five configured payment providers, or the seven days of a week, or three tenant settings — N is bounded, small, and never going to grow. Five extra queries against an indexed primary key is a couple of milliseconds. Rewriting that into a clever eager-load makes the code harder to read and buys you nothing you can measure.
The failure mode I actually see more often in review isn't the missed N+1 — it's someone eager-loading six relations "to be safe" on an endpoint that uses two of them, then wondering why the response got slower.
Optimise the endpoints you can measure. Find the slow one, count its queries, fix that. Then go back to writing features.
My personal take
What I find interesting about N+1 is that it's not really a database problem. It's an abstraction problem.
ORMs sell you the idea that $post->author is just a property. It isn't — it's a network round trip wearing a property's clothes. Ninety-five percent of the time that illusion is exactly what you want, and it makes the code genuinely nicer to write and read. The other five percent, it hands you a 51-query endpoint that no reviewer will ever flag.
So I don't think the lesson is "be careful with ORMs." The lesson is that you can't reason about this by reading — you have to measure it. Query count is the single cheapest performance metric in a web app. It's one number, it's trivially available, and it correlates with slowness better than almost anything else you can check that fast.
We had a version of this on the Puratan Ayurveda build — a product listing that felt sluggish and turned out to be doing per-product lookups for related data. It wasn't clever to fix. It was one with() call. That's usually how this goes: the diagnosis is the hard part, the fix is one line.
Put a query counter in front of your face in dev, put a budget in CI, and this whole class of bug mostly stops happening to you.
The short version
- An N+1 is 1 query for the list plus N more for each item's relation. 50 posts + authors = 51 queries where 2 would do.
- It passes review because the code is idiomatic and correct — it only hurts once N grows in production.
- Fix it with eager loading:
with('author')in Laravel,includesin Rails,includein Prisma,relationsin TypeORM. - Watch the nested case:
with('comments')then touching$comment->userjust moves the N+1 down a level. Usewith('comments.user'). - Don't over-correct. Use
withCount()for numbers,with('author:id,name')for specific columns, constrained closures for slices. - Catch it by counting:
DB::listenin dev, Debugbar or Bullet locally, query-count assertions in tests with 50 seeded records, and a hard budget in CI. - Turn on
Model::preventLazyLoading()outside production so lazy loads fail loudly instead of silently. - If N is genuinely small and bounded, leave it alone. Optimise what you can measure.
Written by the TechKis team — an AI-first engineering studio. techkis.tech

