What they're testing
Whether you connect performance to round-trip count, and whether you measure or guess.
The short answer~30 seconds
One query fetches N parent rows, then a loop issues N more for the children — N+1 round trips. Each may take 0.5ms so no profiler flags them, but 200 rows × (0.5ms + RTT) is several hundred milliseconds. The reliable detection is counting queries per request and alerting on the count, not watching per-query time.
The same screen: fetch 200 posts with their authors. Round-trip time to the database is 2ms.
The long answer
What makes N+1 hard to see is that it never shows up as a hotspot. Every query is cheap, the slow-query log catches none of them, and on a dev box with the database on localhost the RTT is near zero so the page feels fine. It only surfaces in production, where the database is across a network at 1–2ms a hop — and then 200 queries is 400ms with no single query being "slow".
There are three fixes and they aren't equivalent. A JOIN gets everything in one query, but repeats the parent's columns for every child, so a wide one-to-many drags a lot of redundant bytes across. A batched fetch — one query for parents, one WHERE parent_id IN (…), stitched in memory — is what an ORM's include/selectinload does, and is usually the best choice. The third is to not fetch the children at all when the UI doesn't render them, which is the least-considered and cheapest option.
For detection, the approach that actually works is counting queries per request and failing a test when the count exceeds a budget. It turns N+1 from a vague performance concern into a concrete build failure, and more importantly it catches the RECURRENCE — because N+1 almost always comes back a few refactors later.
Same screen, 201 queries and 2 queries
// N+1: 1 truy vấn cha + 200 truy vấn con
const posts = await db.post.findMany({ take: 200 });
for (const post of posts) {
post.author = await db.user.findUnique({ where: { id: post.authorId } });
}
// Gộp: 2 truy vấn, bất kể 200 hay 20.000 hàng
const posts = await db.post.findMany({ take: 200, include: { author: true } });What they'll ask next
?Is a JOIN always better than a batched fetch?
No. With 50 children per parent, a JOIN repeats every parent column 50 times; if the parent has a large text column, the bytes on the wire explode. A batched fetch sends each row exactly once at the cost of one extra round trip. The crossover depends on row width, so measuring is the only answer.
?Does GraphQL make N+1 worse?
Yes, because the client decides the query shape, so nested resolvers produce N+1 by default. That's exactly why DataLoader exists: it batches calls made in the same tick into a single IN (…) query.
These lose points
- Answering "add a cache". A cache hides N+1 on hits and leaves it intact on misses, while adding a layer you now have to invalidate.
- Naming
include/joinand stopping. The second half of the question is the real one.
Sources
- Postgres — `EXPLAIN (ANALYZE)` to compare a JOIN against a batched fetch
- Counting queries per request in tests is what prevents recurrence; most ORMs expose a logger hook for it.