How the N+1 Problem Arises
The N+1 problem happens when resolving a list of N items requires 1 query to fetch the list, plus N additional queries, one per item, to resolve a nested field on each. Querying 40 books and each book's author with a naive resolver like author: (parent) => db.authors.findById(parent.authorId) issues 1 query for the books and then 40 separate queries, one per book, to fetch each author, even when many books share the same author and the same row gets fetched repeatedly. This pattern is invisible in small test data but becomes a severe production bottleneck as list sizes grow.
Cricket analogy: A scorer who looks up each of the 11 batters' career averages individually in a separate reference book, one lookup per player, instead of pulling the whole squad's stats sheet at once, mirrors the N+1 pattern.
What DataLoader Actually Does
DataLoader, the library open-sourced by Facebook, solves this with batching and per-request caching. Instead of executing a fetch the moment .load(id) is called, it collects every .load() call made during the current tick of the event loop into a queue, then on the next tick invokes a single user-supplied batch function once with the full array of collected keys, which is expected to return results in the same order as the input keys. If the same key is requested twice within a request, DataLoader also caches the result and returns the cached promise instead of calling the batch function again, so duplicate authorIds across 40 books collapse into a single fetch of unique IDs.
Cricket analogy: A team analyst who waits until every player has submitted their fitness data request, then sends one consolidated batch request to the medical team, is exactly how DataLoader queues .load() calls before batching.
import DataLoader from 'dataloader';
function createAuthorLoader(db) {
return new DataLoader(async (authorIds) => {
const authors = await db.authors.findByIds(authorIds); // ONE query
const byId = new Map(authors.map((a) => [a.id, a]));
// Must return results in the SAME ORDER as the input keys
return authorIds.map((id) => byId.get(id) ?? null);
});
}
// resolvers.js
const resolvers = {
Book: {
author: (parent, args, context) => context.loaders.author.load(parent.authorId),
},
};
// context is built fresh per request, so create a new loader per request too:
// context: () => ({ loaders: { author: createAuthorLoader(db) } })One Loader Instance Per Request, Never Global
DataLoader instances must be created fresh inside the context function for every incoming request, never as a module-level singleton shared across requests. Because DataLoader caches by key for the lifetime of the instance, a globally shared loader would leak cached results and stale data between unrelated users and requests, and worse, could serve one user's private record to a different user who happens to request the same key. The batching benefit only needs to span a single request's execution, so a per-request instance is both correct and sufficient.
Cricket analogy: A fresh scorebook opened for every new match, never carrying entries over from the last match, mirrors creating a new DataLoader per request rather than reusing one across requests.
A DataLoader instance reused across requests will serve stale or cross-user cached data. Always instantiate loaders inside the per-request context factory function, never at module scope.
- N+1 happens when resolving N list items each triggers its own separate data-source query.
- DataLoader batches every .load() call made in a single event-loop tick into one batch function invocation.
- The batch function must return results in the same order as the input keys array.
- DataLoader also caches results per key for the life of the instance, deduplicating repeated requests within a request.
- Loader instances must be created fresh per request inside the context factory, never as a module-level singleton.
- Reusing a loader across requests risks leaking stale or cross-user data.
- DataLoader turns N separate database round trips into a single batched round trip per unique key set.
Practice what you learned
1. What causes the N+1 problem in a naive GraphQL resolver setup?
2. What is the core mechanism DataLoader uses to reduce database calls?
3. What must a DataLoader batch function guarantee about its return value?
4. Where should a DataLoader instance be created in an Apollo Server or graphql-js setup?
5. What happens if the same key is requested twice via .load() within a single request?
Was this page helpful?
You May Also Like
Resolver Functions Explained
Learn what resolver functions are, how their four arguments work, and how GraphQL servers turn a query into actual data.
Resolving Nested Fields
See how GraphQL resolves deeply nested object fields, including lists of objects, and why field resolution order matters for performance.
The Resolver Chain and Context
Understand how resolvers link together into a chain across the query tree and how the shared context object flows through that chain.
Related Reading
Related Study Notes in Web Development
Browse all study notesWebSockets Study Notes
Web Development · 30 topics
Web DevelopmentWebAssembly Study Notes
WebAssembly · 30 topics
Web DevelopmentgRPC Study Notes
Protocol Buffers · 30 topics
Web DevelopmentSpring Boot Study Notes
Java · 30 topics
Web DevelopmentFlask Study Notes
Python · 30 topics
Web DevelopmentDjango Study Notes
Python · 30 topics