The N+1 problem shows up in every GraphQL API that maps a list field to a list of children with their own resolvers. It's not a GraphQL-specific bug so much as an ORM-lazy-loading problem that GraphQL's field-by-field resolution model makes trivially easy to write by accident — and DataLoader is the standard fix, though it's narrower than people assume.
How N+1 actually happens
Say a query resolves a list of ten posts, then for each post asks for its author. If Post.author is implemented as its own resolver that runs a query for one author by id, GraphQL calls that resolver independently for every post in the list — one query to fetch the ten posts, then ten more queries, one per author, even when several posts share the same author. That's the "N+1": one query for the list, N queries for the per-item relation. Nothing in GraphQL causes this on purpose; it falls out of resolvers being independent functions that don't know what their sibling resolvers are doing in the same tick.
const resolvers = {
Post: {
// called once per post in the result set — N+1 without a loader
author: (post) => db.query('SELECT * FROM users WHERE id = $1', [post.authorId]),
},
};
How DataLoader batches calls
DataLoader doesn't change how resolvers are written so much as change what they call. Each resolver calls loader.load(key), which returns a promise but doesn't hit the database immediately. DataLoader collects every .load() call made during the current tick of the event loop, and once that tick ends (via process.nextTick or a microtask), it fires your batch function exactly once with the full array of collected keys.
const userLoader = new DataLoader(async (userIds) => {
const users = await db.query(
'SELECT * FROM users WHERE id = ANY($1)',
[userIds]
);
const byId = new Map(users.map(u => [u.id, u]));
return userIds.map(id => byId.get(id) || null); // same order, same length
});
// in the resolver
author: (post) => userLoader.load(post.authorId),
Ten posts sharing four distinct authors now produce one batched query for those four ids instead of ten individual ones — and DataLoader also deduplicates identical keys requested more than once in the same batch.
The batch function's contract
The batch function has one non-negotiable rule: it must return a promise that resolves to an array the same length and in the same order as the array of keys it was given. If a requested id has no matching row, that slot must still be filled — with null or an Error instance — rather than simply omitted. Building the array with a plain .filter() on the database result is the most common way to violate this: any missing row shifts every entry after it out of alignment with its key, and results start silently attaching to the wrong post.
DataLoader must be created per request
DataLoader caches within its own lifetime — every .load(key) call for a key it's already resolved returns the cached promise instead of re-batching. That cache has no eviction and no TTL, which is fine as long as the loader itself is short-lived. The mistake is instantiating one DataLoader at server startup and reusing it as a singleton across every request: the cache then quietly serves user A's data to user B once it's warm, and results never reflect a write that happened after the entry was cached.
Create DataLoader instances inside the per-request context function, not at module scope. A fresh set of loaders per request keeps the batching win (multiple resolvers in the same request still get coalesced) without leaking cached data across requests or users.
What DataLoader doesn't fix
DataLoader solves duplicate round trips for the same kind of lookup — it does nothing about over-fetching. If userLoader's batch function always does SELECT * FROM users, it fetches every column even when the query only asked for name. Getting selection-set-aware fetching — trimming the batch query to just the columns the current GraphQL query actually selected — requires additional work, usually inspecting the resolver's info argument (the GraphQLResolveInfo) to see the requested field set, or reaching for a heavier tool like a query-planning layer. DataLoader collapses N+1 into 1+1; it doesn't shrink what's inside either query.
Wrapping up
N+1 is a consequence of resolvers being independently-called functions, DataLoader fixes it by batching same-tick .load() calls into one request, and the contract that makes that safe — same length, same order, one loader per request — is easy to get subtly wrong. It's the standard first fix, not the last optimization; over-fetching inside the batch itself is a separate problem.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.