The N+1 query problem is a pattern where fetching N records triggers one extra query per record — N+1 round trips instead of one or two. It is the most common serious performance bug in data-backed applications, and the most camouflaged: every individual query is fast, the code reads perfectly, and the page works — right up until real data sizes arrive.
Key takeaways
| Question | Answer |
|---|---|
| The shape | 1 query for the list + N queries for relations = N+1 round trips |
| The cause | Lazy loading touched inside a loop — invisible queries per iteration |
| The signature | Many fast identical queries — slow-query logs never see it |
| The exits | Eager loading · joins · batched IN · request-scoped loaders |
| The multiplier | Round-trip latency × page size — brutal over networks |
The bug, in the open
// 1 query: fetch 100 posts
const posts = await postRepo.findRecent(100);
for (const post of posts) {
// +1 query PER POST — post.author looks like a property,
// but lazy loading runs: SELECT * FROM users WHERE id = ?
render(post.title, (await post.author).name);
}
// Query log: 1 + 100 = 101 round trips for one page render
The fix, as SDKs express it — declare the relation up front, and the platform fetches it in the same request:
// JavaScript / Node.js — Back4app JS SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
const query = new Parse.Query('Comment');
query.equalTo('post', post);
query.include('author'); // eager-load the pointer
const comments = await query.find(); // one round trip, total
comments.forEach((c) =>
render(c.get('text'), c.get('author').get('username')) // already loaded
); // Flutter / Dart — Back4app Flutter SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
final query = QueryBuilder<ParseObject>(ParseObject('Comment'))
..whereEqualTo('post', post.toPointer())
..includeObject(['author']); // eager-load the pointer
final response = await query.query(); // one round trip, total // iOS / Swift — Back4app Swift SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
let query = Comment.query("post" == post)
.include("author") // eager-load the pointer
query.find { result in
if case .success(let comments) = result { // one round trip, total
comments.forEach { render($0.text, $0.author?.username) }
}
} // Android / Kotlin — Back4app Android SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
val query = ParseQuery.getQuery<ParseObject>("Comment")
query.whereEqualTo("post", post)
query.include("author") // eager-load the pointer
query.findInBackground { comments, e -> // one round trip, total
if (e == null) comments.forEach {
render(it.getString("text"), it.getParseObject("author")?.getString("username"))
}
} The cost, multiplied out
The arithmetic the explainers skip — total time ≈ list query + (N × round-trip latency):
| Page size N | @1 ms/query | @5 ms/query | Batched (1–2 queries) |
|---|---|---|---|
| 10 | ~11 ms | ~55 ms | ~10 ms |
| 100 | ~101 ms | ~505 ms | ~12 ms |
| 1,000 | ~1.0 s | ~5.0 s | ~20 ms |
Published fixes match the math: pages dropping from 1.4 s to 0.16 s, endpoints speeding up 30× and more. Two corollaries worth pinning: pagination caps N — a bounded page size bounds the blast radius even before the real fix; and the network version is worse — when each of the N calls is an HTTP request rather than a local query, multiply by tens of milliseconds instead of single digits.
The four exits
| Fix | How | Reach for it when | Watch for |
|---|---|---|---|
| Eager loading / include | Declare relations on the query | The screen always needs the relation | Over-including bloats payloads |
| Join | One statement fetches both | Relational engine, report-shaped reads | Row explosion on wide joins |
| Batched IN | Collect keys, fetch once | Any stack, even hand-rolled | One extra round trip (fine) |
| Request-scoped loader | Auto-batch during execution | GraphQL resolvers, layered code | Must be per-request, not global |
The last row is GraphQL’s famous case: resolvers fire per parent object, recreating the loop server-side, and the loader pattern — collect keys in one tick, fetch once, memoize per request — is the industry-standard cure. A subtle rule rides along: loaders are request-scoped; a global one becomes a stale cache with authorization bugs.
Detection: hunt the fast queries
The N+1 signature is inverted from normal performance work: you are looking for many fast identical queries, not one slow one — which is why slow-query logs, the usual tool, never see it. The methods that do: ORM debug logging (the same statement, N different parameters, one render); APM span views, where the waterfall of identical short spans is unmistakable; and the cheapest of all — count the queries for one page load in development, with a threshold in your head: a list page should cost queries in the single digits, not in multiples of its rows. The write-side twin deserves its audit too: an insert-per-item loop is N+1 for writes, fixed with bulk operations.
Common use cases
- List views with authors, owners, or statuses — the canonical home: every feed, inbox, and table joining people to items.
- GraphQL APIs — nested list fields are structurally N+1 until loaders exist.
- Document databases — one find per referenced document; fixed with include, batched IN, or embedding, per the modeling rules.
- Microservice fan-outs — a list from service A, one HTTP call to service B per item; compound endpoints and BFFs exist to end this.
- Background jobs — the loop that processes 10,000 records with two queries each, quietly costing hours.
Lazy vs. eager loading: a decision matrix
| Load eagerly when… | Stay lazy when… |
|---|---|
| The relation renders on every row | The relation is behind a click |
| The loop is the access pattern | Access is one-record-at-a-time |
| N is page-sized or bigger | N is guaranteed tiny |
| Latency is user-facing | A background task can afford drift |
| You just fixed this bug here | You have measured, not assumed |
And the standing audit rule that outlives any matrix: any relation touched inside a loop is guilty until the query log proves otherwise.
Limitations and trade-offs
- Eager loading can overcorrect. Including heavy relations everywhere trades N+1 for bloated payloads and wide joins — include what the screen renders, not the whole graph.
- Joins have their own cliff. One-to-many joins duplicate parent rows per child; at high fan-out the two-query batched fetch beats the single join.
- Loaders add machinery. Request scoping, cache invalidation within the request, and batching windows are real code — the price of automatic batching.
- Frameworks re-introduce it silently. Serializers, template helpers, and “just one more field” reviews are how fixed pages regress; the query-count check belongs in CI, not memory.
- The fix is per-path, not global. N+1 is an access-pattern bug; every new screen re-asks the question.
N+1 on Back4app
Back4app is an open-source Backend-as-a-Service (BaaS) platform that combines a managed database, auto-generated REST and GraphQL APIs, authentication, file storage, and Cloud Code serverless functions. Its SDKs make the fix the idiom rather than the remediation: relations are typed Pointers, and include() — the code tabs above — fetches them in the same request, batched server-side. The GraphQL API resolves nested queries without per-field fan-out, and pagination defaults bound N before it grows teeth. The loop that causes N+1 has no natural way to be written — which is the best kind of fix: the one nobody has to remember.
Frequently asked questions
What is the N+1 query problem in simple terms?
You fetch a list of N records with one query, then your code makes one more query per record for its related data — a hundred posts become a hundred and one queries. Each query is individually fast, which is exactly why the problem hides: nothing is slow enough to alarm anyone until the page makes hundreds of round trips.
What causes N+1 queries?
Lazy loading as a default. ORMs and SDKs let you navigate relations like object properties — post.author — and transparently run a query when you touch one. Put that access inside a loop over N results and you have silently written N queries. The abstraction that made data access pleasant also made the round trips invisible.
How do you fix the N+1 problem?
Four standard exits, best chosen per case: eager loading — tell the query up front to include the relation; a join that fetches both in one statement; batching — collect the N foreign keys and fetch them in one contained-in query; and request-scoped loaders that batch automatically. All four turn N+1 round trips into one or two.
How much slower is N+1 really?
Multiply it out: each round trip costs one to five milliseconds before the query even runs, so 100 rows at 5 ms adds half a second versus one ~10 ms batched query. Published real-world fixes report pages going from about 1.4 seconds to 0.16, and API endpoints speeding up thirtyfold. The math worsens linearly with page size — and catastrophically over networks.
Why is the N+1 problem so common in GraphQL?
Because resolvers run per field, per object. A query for posts with their authors runs the posts resolver once and the author resolver N times — the ORM loop reborn server-side. The canonical cure is the loader pattern: a request-scoped batcher that collects the author IDs during execution and issues one batched fetch, memoized for the request.
How do you detect N+1 queries?
Look for many fast identical queries, not one slow one — that is the signature. ORM debug logging shows the repeated statement with different parameters; slow-query logs miss it entirely because each query is quick; APM tools flag the span pattern explicitly. The habit that catches it early: read the query log for one page render, and count.
Does the N+1 problem happen in document databases and REST APIs?
Everywhere data has relations. In document stores it is one find per referenced document — fixed by include mechanisms, batched contained-in queries, or embedding. Over REST it is a list endpoint plus one HTTP call per item — worse than the database version because network latency dwarfs query latency; compound endpoints, batch endpoints, and query languages exist largely to kill it.
Is lazy loading always wrong?
No — it is wrong in loops. Lazy loading is exactly right when related data is rarely needed: pay for it on the one record that needs it rather than eagerly for all N. The discipline is knowing which access pattern each screen has: always-needed relations load eagerly, rarely-needed ones lazily, and anything inside a loop gets audited.