Understanding the N+1 Problem in GraphQL (and Why It’s Not Just GraphQL)

TL;DR
The N+1 problem is a general data-access anti-pattern that can appear in REST, resolver-based GraphQL, and federated GraphQL at different layers of the stack. WunderGraph’s breadth-first loading approach can help batch cross-subgraph work at the router layer, but it does not remove the need for batching inside subgraphs. Even when the router batches entity references, a subgraph can still reintroduce N+1 if its reference resolvers fetch one record at a time. DataLoader is a common resolver-level fix that provides scheduling-window batching plus request-scoped caching, but joins, eager loading, denormalized views, or a single set-based query can be better when the access pattern is predictable. Detection usually means correlating a GraphQL operation with repeated backend calls using database logs, traces, or APM tools. Traces may surface symptoms before they identify the root cause.
A single API request can quietly fan out into hundreds of backend calls. That is the N+1 problem, and it shows up in REST, monolithic GraphQL, and federated GraphQL alike. The shape is the same in each case. What changes is where the repeated work lands — on the client, at the router, inside a subgraph, or in the database.
This post expands on Claim 4 from our GraphQL vs REST fact-check , which argued that different API styles do not eliminate complexity. They move it to different layers. N+1 is one of the clearest examples: in REST it often appears as many client-side requests, in GraphQL as resolver fan-out inside a single operation, and in federation as work distributed across router and subgraph layers.
You can read more about this shift in Where Does API Complexity Live?
The N+1 pattern is one query to fetch a list, plus one additional query per item to fetch related data. A single GraphQL operation can trigger one backend call or hundreds, depending on how resolvers are implemented.
The N+1 problem happens when your API fetches a list of items using one query, and then issues an additional query for each item in the list.2
In resolver-based GraphQL servers, execution does not automatically batch sibling field resolvers, so repeated data access can be introduced easily and the cost of each field's fetch isn't coordinated by default. 2 The same pattern can appear in REST clients, ORMs, and batch APIs whenever related data is loaded one item at a time instead of via set-based operations.3 4
A common example is a User type with a one-to-many posts field. A naive implementation wires two resolvers:
Query.usersrunsSELECT * FROM users(one query).User.postsrunsSELECT * FROM posts WHERE user_id = ?once per user (N queries).
For 50 users, the server issues 51 backend calls.
Here's what that looks like in code — and how DataLoader can fix it:
With a request-scoped DataLoader, those 50 User.posts resolver calls can be batched into one posts lookup, reducing the backend work from 51 calls to 2: one call for users and one call for posts.
N+1 in federated GraphQL is easy to misread as a gateway problem. WunderGraph’s breadth-first loading approach can help batch cross-subgraph work at the router layer, but it does not remove the need for batching inside subgraphs. Even when the router batches entity references, a subgraph can still reintroduce N+1 if its reference resolvers fetch one record at a time, for example when __resolveReference or nested resolvers fan out per entity.
Understanding the GraphQL federation N+1 problem requires separating two concerns: router vs subgraph batching.
- Router layer. When Cosmo Router resolves a federated query, it may send a batch of entity representations to a subgraph in a single request (the
_entitiesquery). WunderGraph’s breadth-first loading approach is one implementation strategy. - Subgraph layer. Inside the subgraph,
__resolveReferenceresolves entity references, but whether those references are processed efficiently depends on the subgraph’s implementation. If the resolver performs a datastore lookup per entity representation rather than batching those lookups, the subgraph reintroduces N+1 even though the router sent a single batched request.
Cross-subgraph dependencies via @requires can increase the amount of coordinated work. When a field in one subgraph depends on data from another, the router fetches the required fields first and passes them to the dependent subgraph as part of the entity representation. Each hop is a chance for subgraph-internal N+1 if reference resolvers aren't batched. A query that crosses multiple subgraphs can still generate many backend calls if each __resolveReference or nested resolver performs single-row lookups.
Here's the pattern in concrete terms. An accounts subgraph owns User, and a posts subgraph owns Post:
The posts subgraph contributes the posts field to the User entity, so it resolves that field even though accounts owns the rest of User. When a client queries { users { posts { title } } }, the router fetches users from accounts, then sends a batch of user representations to posts to resolve their posts. That batch arrives at the posts subgraph as a single _entities query.
The problem happens inside posts in this version:
The router may look efficient because it sends a batched _entities request, but that does not guarantee efficient work inside the subgraph. The subgraph can still issue N DB calls unless the User.posts resolver uses a DataLoader or equivalent. The fix is the same as in monolithic GraphQL: batch at the layer where the repeated fetches actually happen, which in this case is inside the subgraph.
N+1 usually becomes a production problem when lists grow, traffic increases, or many requests hit the same resolver path at once. The extra calls compete for database connections, worker threads, and upstream service capacity.
In production, the failure mode is usually not just “one request got slower.” The extra calls can amplify tail latency, increase timeout rates, and put pressure on shared infrastructure.
N+1 is a data-fetching pattern: a similar “one request for the list, one request per item” shape can show up in plain SQL, ORMs, and REST clients as well as in GraphQL.
Plain SQLThis is the classic N+1 pattern in relational databases. It runs one query to fetch all users, then one additional query per user to fetch posts.
ORM with lazy loadingThis looks clean, but a lazily loaded user.posts() can hide a separate query per user under the hood.
Here the N+1 shows up as client-side REST calls instead of backend resolver calls: one request for the collection, then N requests for per-user posts. This is a similar N+1 shape to GraphQL; the difference is whether the fan-out happens on the client, in resolvers, or at another backend layer.
REST can exhibit the same anti-pattern when a client or backend issues one request for a collection and then one request per item, but that is an implementation pattern rather than a property of REST itself. REST can also hide the same pattern server-side when a gateway or backend endpoint fans out to fetch related records one at a time.4 In typical monolithic GraphQL servers, N+1 is hidden behind a single client request and shows up as backend fan‑out inside the resolver layer. Clients can also create an N+1 pattern by issuing multiple related operations instead of one broader operation, although the more characteristic GraphQL case is resolver-level backend fan-out. This pattern makes it easier to ship accidentally and harder to observe unless you are watching backend query counts, subgraph calls, traces, or APM data.
Create loaders per request. Instantiate loaders inside the request context, not globally. GraphQL.js examples typically create loaders per request, and Netflix DGS documents its data loaders as request-scoped by default.2 5
Replace per-item fetches with
.load(). In resolvers that fetch related data by key, callctx.loader.load(id)instead of querying the database directly.Issue related loads together. DataLoader provides scheduling-window batching plus request-scoped caching: it batches
.load(id)calls issued within the same scheduling window and dispatches them together as a batch.6 Code can use.load()and still fail to batch if each load is awaited before the next one is issued. UsePromise.all(ids.map((id) => loader.load(id)))when loading multiple IDs in the same resolver. Awaiting each load before issuing the next one, including in a for-loop, usually prevents batching.Return results in key order. The batch function receives an array of keys and must return results aligned to the input keys. Fetch by set, group the results, then map them back to the original key order, and choose a missing-value policy that fits your loader, such as
nullor an error.
DataLoader is a common resolver-level fix, but joins, eager loading, denormalized views, or a single set-based query can be better when the access pattern is predictable.
ORM eager loading. Many ORMs can fetch parents and children together using joins, subqueries, or select-in loading. This can replace per-row lazy loads without adding DataLoader to every resolver.
Set-based queries. For stable, read-heavy access patterns, a single set-based SQL query using joins, CTEs, or a denormalized view can be simpler and faster than a chain of DataLoaders. This fixes N+1 at the data-access layer instead of patching it at the resolver layer.
WunderGraph’s breadth-first loading. WunderGraph’s breadth-first loading can help batch cross-subgraph work at the router layer instead of every team wiring loaders by hand, but it does not remove the need for batching inside subgraphs.
If the data can be fetched efficiently in one set-based query, use the query. If the ORM can eager-load the relationship cleanly, use the ORM.
How Cosmo addresses this
Cosmo can address one class of N+1 problem at the router/planner layer by batching execution across subgraph boundaries, but that does not automatically eliminate N+1 inside subgraphs, where resolvers may still issue one datastore call per entity or rely on unbatched data access.
True N+1 detection still requires request traces, subgraph call counts, or backend query logs, even when router-level batching is in place, and even when planner-level algorithms reduce some categories of fan-out.
Learn more in our breadth-first data loading post .7
Detecting N+1 is less about spotting GraphQL syntax and more about correlating one operation with the backend work it triggers. The clearest signal is the same backend call repeated many times with different IDs inside a single GraphQL operation.
Tracing and APM tools can show the same pattern from the request side: one GraphQL operation fans out into many database calls, REST calls, or subgraph requests. In federated graphs, subgraph request counts are especially useful because N+1 may appear at the router boundary, inside a subgraph, or both.
Specifically: a span pattern that scales linearly with entity count is a strong sign that reference resolution is still happening per entity. If query latency scales roughly linearly with list size, same problem. Router traces may still look fine at a glance, or only show indirect symptoms such as elevated latency or high subgraph span counts, while the exact per-entity fan-out stays hidden inside the subgraph.
Track tail latency and backend-call counts per operation where your telemetry supports it.
- DataLoader is a common resolver-level fix: joins, eager loading, denormalized views, or a single set-based query can be better when the access pattern is predictable; WunderGraph’s breadth-first loading can reduce cross-subgraph fan-out as a complement.1 7
- Federation can move N+1 to a different layer, so router-level batching and subgraph-level batching both matter. Even when the router batches entity references, a subgraph can still reintroduce N+1 if its reference resolvers fetch one record at a time.1
- In resolver-based GraphQL servers, the default execution model makes N+1 easy to introduce, not inevitable. Avoiding it requires deliberate choices at the resolver, data-access, or planner layer.
That is the real answer to Claim 4 in our GraphQL vs REST fact-check: GraphQL can make N+1 easier to create, but the fix is not “use REST.” The fix is usually to batch or reshape data access at the layer where the repeated work is created, and to be explicit about which layer owns that work, as we argue in the API complexity post.
[1] Apollo GraphQL. "Handling the N+1 Problem." https://www.apollographql.com/docs/graphos/schema-design/guides/handling-n-plus-one
[2] GraphQL.js. "Solving the N+1 Problem with DataLoader." https://www.graphql-js.org/docs/n1-dataloader/
[3] REST API Tutorial. "What is N+1 Problem in REST?" https://restfulapi.net/rest-api-n-1-problem/
[4] Kheyrollahi, A. "Web APIs and the n+1 Problem." InfoQ. https://www.infoq.com/articles/N-Plus-1/
[5] Netflix DGS Framework. "Data Loaders (N+1)." https://netflix.github.io/dgs/data-loaders/
[6] GraphQL Foundation. "DataLoader" (reference implementation). https://github.com/graphql/dataloader
[7] WunderGraph. "Dataloader 3.0: A new algorithm to solve the N+1 Problem." https://wundergraph.com/blog/dataloader_3_0_breadth_first_data_loading
Frequently Asked Questions (FAQ)
No. GraphQL's resolver model makes N+1 easy to introduce because each field can fetch data independently, but the pattern itself exists in REST, ORMs, and batch APIs. In GraphQL, N+1 most often manifests server-side in the resolver layer and is hidden behind a single client operation, making it easier to ship accidentally and harder to observe without backend instrumentation. Client-side N+1 across multiple operations is also possible.
No. DataLoader is a common resolver‑level solution, especially in GraphQL.js/Node ecosystems, and works well for key-based lookups, but it's not always the best choice. For predictable access patterns, a single set-based SQL query using joins or CTEs can out‑perform DataLoader chains. ORM eager loading, denormalized views, and WunderGraph’s breadth-first loading approach are all valid alternatives depending on your data layer and graph architecture.
The most common cause is sequential `await` inside a loop. DataLoader batches `.load()` calls issued within the same scheduling window, typically one JavaScript event loop tick. Awaiting `loader.load(id)` before issuing the next load (for example in a for-loop) usually puts each load in a separate window and prevents batching. Use `Promise.all(ids.map(id => loader.load(id)))` to issue all loads concurrently before awaiting the results.
Look for the same query shape executed many times with different IDs inside a single GraphQL operation. Database logs (like Postgres `pg_stat_statements`) and slow-query logs show repeated per-ID calls. Tracing and APM tools that attribute backend calls to GraphQL operations reveal fan-out. In Federation, track subgraph request counts. N+1 may appear at the router boundary, inside a subgraph, or both. Focus on tail latency (p95/p99) rather than averages.
No. Federation can batch entity fetches at the router boundary when resolving references, but it does not automatically batch subgraph internals. If a subgraph's `__resolveReference` resolver fetches each entity representation separately (for example, calling the database once per product ID), the subgraph reintroduces N+1 even though the router sent a batch. DataLoader or equivalent batching is still required inside each subgraph.
Skip DataLoader when the entire dataset can be fetched efficiently in one query. If your graph is backed by a well-indexed SQL query or denormalized view, using joins or ORM eager loading can be simpler and more predictable than wiring loaders. Also skip it when lists are small and the backend is extremely fast, since the overhead of maintaining loaders can outweigh the benefit. For stores with native batch APIs (key-value multi-get, columnar warehouses), DataLoader becomes a thin wrapper rather than a primary solution.
Content Manager
Brendan Bondurant is the Content Manager at WunderGraph, owning technical content across GraphQL Federation, API tooling, and developer experience. He partners with leadership on product and company messaging and works cross functionally to align positioning, terminology, and content strategy across channels.

