GraphQL's N+1 Problem in NestJS: What DataLoader Fixes and What It Doesn't

Khanh Nguyen
Khanh Nguyen
(Updated: )
Listen to this article0 / 0
Minimalist editorial illustration showing a hand bundling multiple falling blocks into a single column on a muted slate background. Photo: AI/BytePith.

A NestJS resolver that returns a list of posts, each with its author, issues one query for the posts and then one additional query per post by default. Request 200 posts in a single GraphQL operation and Postgres sees 201 round trips for a page a REST endpoint with a SQL join would have answered in one. This is the N+1 problem, and it is a direct consequence of how GraphQL resolves fields, not a bug in any particular library.

Why Per-Field Resolution Produces Per-Row Queries

GraphQL executes a query by calling a resolver function for every field in the requested shape, not by planning the whole query up front the way a SQL engine does. When a schema defines Post.author as a field with its own resolver, the GraphQL execution engine calls that resolver once for every post object already in memory. In NestJS's code-first approach, this is exactly what a @ResolveField() method does: it receives the parent object through @Parent() and is free to run its own database call for each one it is handed.

That design is what makes GraphQL good at composing data from different sources without a single endpoint accumulating every possible field. It is also what turns a list of N parents with a related field into 1 query for the parents plus N queries for the relation, unless something intervenes to batch those N calls together.

Measuring the Cost: Resolver Overhead in a NestJS Benchmark

The overhead is not only about the extra database round trips. A published k6 load test against a NestJS GraphQL server compared three ways of returning the same field: passing it straight through from the parent object with no resolver, resolving it with a plain one-to-one resolver, and resolving it with a resolver that reads the parent through the @Parent() decorator. At one virtual user across a 30-second run, the version with no resolver handled 1,353 requests. The plain resolver handled 582, a 57% drop. The version using @Parent() handled 346, a 74% drop from the baseline before any database call is even added.

Requests handled in a 30-second load test, by resolver strategyA NestJS GraphQL benchmark shows a plain 1:1 field resolver handles 57% fewer requests than returning the field directly, and a resolver using the @Parent() decorator handles 74% fewer.Field Resolvers Cost Throughput in NestJS GraphQLRequests completed in a 30s k6 test at 1 virtual user, single-field queryDirect field (baseline)1,353 reqs1:1 field resolver582 (-57%)@Parent() decorator346 (-74%)02805608401,1201,400 reqsSource: tniezurawski/nestjs-resolvefield-performance (GitHub), k6 benchmark

That gap exists before a single database query is added to either resolver. Once each @Parent() call also fires its own SQL statement per row, the two costs compound: resolver-dispatch overhead from the GraphQL execution engine, plus a query-count problem from the data layer.

Batching the Fan-Out with DataLoader and TypeORM

DataLoader, maintained under the official graphql GitHub organization, addresses the second half of that cost. It batches every .load(key) call issued within a single tick of the event loop into one call to a batch-loading function, and caches the result for the life of the request. Each DataLoader instance needs its own cache per request rather than one shared across every user hitting the server, which is why the loader itself has to be rebuilt on every operation.

The most common NestJS pattern wraps the loader in a Scope.REQUEST provider. It works, but NestJS's own documentation is explicit about what it costs: a request-scoped provider's scope bubbles up the injection chain, so any resolver, service, or controller that depends on it becomes request-scoped too, and Nest has to instantiate that whole chain fresh on every operation instead of once at startup. NestJS's docs put the latency cost of an isolated request-scoped provider at roughly 5%, but that figure assumes the request scope stays contained to the loader itself. If the same loader provider gets injected into a shared service that other, unrelated resolvers also depend on, the request scope spreads into parts of the app that had no reason to pay for it, and the resulting instantiation and garbage-collection churn compounds the per-request cost the resolver benchmark above already measured.

Building the loader inside the GraphQL module's context factory avoids the problem, since it creates a fresh DataLoader per operation without pulling any provider into Nest's request scope at all:

TYPESCRIPT
GraphQLModule.forRootAsync({
  driver: ApolloDriver,
  imports: [AuthorModule],
  inject: [AuthorService],
  useFactory: (authorService: AuthorService) => ({
    autoSchemaFile: true,
    context: () => ({
      authorLoader: new DataLoader(async (ids) => {
        const authors = await authorService.findByIds(ids as number[]);
        const byId = new Map(authors.map((a) => [a.id, a]));
        return ids.map((id) => byId.get(id));
      }),
    }),
  }),
}),
TYPESCRIPT
@ResolveField(() => Author)
author(
  @Parent() post: Post,
  @Context() { authorLoader }: { authorLoader: DataLoader },
) {
  return authorLoader.load(post.authorId);
}

AuthorService stays a normal singleton, injected once when the module bootstraps. Only the DataLoader instance, a plain object with no NestJS provider wrapper around it, gets rebuilt per request, so nothing else in the injection chain is forced into request scope.

The batch function above works because Post.author is a many-to-one relation: every key maps to exactly one entity, so a plain Map<id, entity> lookup returns results in the id order DataLoader expects. The reverse direction, an Author.posts field returning every post written by each author, is one-to-many and needs a different batch function, one that groups the fetched rows by authorId and returns an array of posts per key instead of a single entity per key. Reusing the 1:1 lookup shape for a one-to-many relation is a common mistake: it silently returns at most one child per parent instead of the full list, with no error to signal that the data is wrong.

For 200 posts, either loader shape produces two queries total: one for the posts, one WHERE id IN (...) call carrying every distinct author ID gathered across the batch. TypeORM's own relations option or a leftJoinAndSelect call in its QueryBuilder solves the same case in one query instead of two, by having Postgres perform the join directly rather than round-tripping the IDs back to the application first. Both are documented, current TypeORM patterns; the choice between them is not about which is more modern, it's about whether the parent and the relation are requested by the same resolver or by two independent ones that don't know about each other's data needs, which is the situation DataLoader was built for.

What DataLoader Doesn't Fix, and When a Join Wins

DataLoader collapses N queries into close to one, it does not collapse them into the single join a REST endpoint or a TypeORM leftJoinAndSelect call can produce when the parent and child are fetched by the same code path. The batched IN (...) query still round-trips separately from the query that fetched the parents, and DataLoader's cache lives only for the duration of one request, so it does nothing for a second, unrelated request arriving a moment later. For a resolver where the parent and its relation are always requested together, a query-builder join eliminates the second round trip DataLoader still makes. For a resolver where different clients request different combinations of nested fields, unconditionally joining wastes the flexibility that made a GraphQL layer worth building in the first place. The two techniques solve different shapes of the same problem, and using DataLoader everywhere a join would do just as well trades a small amount of clarity for no measurable benefit.

A third option sits between those two: inspecting the query before deciding which one to run. The info argument every GraphQL resolver receives carries the abstract syntax tree of the requested selection, including which nested fields the client actually asked for. NestJS exposes this through its @Info() parameter decorator, and libraries such as graphql-fields turn that AST into a plain object of requested field names. A parent-level resolver can read that object before querying, and only apply leftJoinAndSelect when the client's selection set actually includes the relation, falling back to the DataLoader-batched path otherwise. That adds a lookahead step to every resolver that uses it, and it pays off cleanly for a one-level relation; a schema with several layers of optional nested fields makes the lookahead logic itself the thing that needs maintaining.

The Adoption Numbers Behind the Over-fetching Debate

None of this is a reason to treat GraphQL as a default. Postman's 2025 State of the API Report, based on a survey of more than 5,700 developers and API professionals, found REST still in active use at 93% of surveyed teams, GraphQL at 33%, and gRPC at 11%, with most teams running more than one style rather than replacing REST outright.

Share of API teams using each architecture style, 2025Postman's 2025 State of the API survey of over 5,700 developers found 93% of teams use REST, 33% use GraphQL, and 14% use gRPC, with most teams running more than one.REST Still Dominates Even Where GraphQL ShipsShare of surveyed API teams reporting active use, one team can use more than one styleREST93%GraphQL33%gRPC14%0%20%40%60%80%100%Source: Postman 2025 State of the API Report, n=5,700+ developers

That pattern matches what the N+1 problem itself suggests: GraphQL earns its complexity when several clients genuinely need different shapes of the same graph, and a schema-first API can serve a mobile app three fields while a dashboard pulls forty from the same types without either client waiting on an endpoint built just for it. Query-count and resolver-dispatch overhead is the price of that flexibility, and DataLoader plus a well-placed TypeORM join are how a NestJS team pays that price down to close to what a single join would have cost, not how they eliminate it. Where every client wants the same fixed shape, that price buys nothing back, and a REST endpoint with a join was already the cheaper answer.

Comments (0)

Sort by:

No comments yet.

Be the first to share your perspective on this topic.