DataLoader and solving the N+1 problem
Why a nested field causes one query per parent, how batching and per-request caching work, and the N+1 cases a loader does not fix.
The shape of the problem
query {
books(first: 50) {
nodes {
id
title
author { name }
}
}
}-- 1 query for the list
select * from book order by created_at desc limit 50;
-- then 50 more, because Book.author resolves once per node
select * from author where id = 1;
select * from author where id = 1;
select * from author where id = 7;
-- ... 48 moreThe resolver has no way to know it is one of fifty. DataLoader solves it by deferring resolution to the end of the current event-loop tick, collecting the keys requested so far, and issuing a single batched query.
💡
A loader is not a cache for the request alone - it must be created per request. A module-level loader leaks data between users and serves stale objects after a mutation.
Creating and using loaders
import DataLoader from "dataloader";
export function createLoaders(db) {
return {
authorById: new DataLoader(async (ids) => {
const rows = await db.author.findMany({ where: { id: { in: ids } } });
const byId = new Map(rows.map((r) => [r.id, r]));
// the returned array must be the same length and order as the keys
return ids.map((id) => byId.get(id) ?? null);
}),
booksByAuthorId: new DataLoader(async (authorIds) => {
const rows = await db.book.findMany({ where: { authorId: { in: authorIds } } });
const grouped = new Map(authorIds.map((id) => [id, []]));
rows.forEach((b) => grouped.get(b.authorId)?.push(b));
return authorIds.map((id) => grouped.get(id) ?? []);
}),
};
}
const resolvers = {
Book: { author: (book, _a, ctx) => ctx.loaders.authorById.load(book.authorId) },
Query: { books: (_p, args, ctx) => ctx.db.book.findPage(args) },
};| Feature | Behaviour | Caution |
|---|---|---|
| Batching | One call per tick, keys deduplicated | Needs an array back in key order |
| Caching | Same key in one request returns the same promise | Cache is not cleared after a mutation unless you call clear() |
loadMany | Resolves an array of keys | Individual rejections still reject the whole call unless handled |
| Error handling | One failing key rejects that key only | Use load per key, not loadMany, if you need graceful degradation |
clear() / clearAll() | Purges the cache | Required after a mutation in the same request |
Mutation: {
async updateAuthor(_p, { id, name }, ctx) {
const updated = await ctx.db.author.update({ where: { id }, data: { name } });
ctx.loaders.authorById.clear(id); // otherwise the stale value is served
return updated;
},
}What a loader does not fix
- A loader cannot batch a query that needs data from the parent before it can even build the filter - it batches keys, not arbitrary logic.
- Filtering a large collection in the resolver (loading all rows then filtering in JavaScript) is still an N+1 in disguise. Push the filter into the query.
- Per-object permission checks that call the database are N+1 as well; load the permission set once per request and reuse it.
- Two loaders can still produce two queries per level. Merging related fetches into one batched query with a join is sometimes worth it.
- Monitor query counts per operation, not just per request: a resolver that quietly counts as three queries will be invisible in aggregate latency.
// a plugin that counts database calls per operation
const dbCounter = {
async requestDidStart({ contextValue }) {
contextValue.dbCalls = 0;
return {
async willSendResponse({ contextValue, response }) {
if (contextValue.dbCalls > 10) {
log.warn({ op: contextValue.operationName, dbCalls: contextValue.dbCalls }, "high query count");
}
},
};
},
};FAQ
Why does my loader return results in the wrong order?
The batch function must return an array with exactly one entry per key, in the same order as the input. Building it with a map over the fetched rows reorders the results - always map over the input keys instead.
Should the loader hold a cache across requests?
No. Cross-request caching needs a shared store with explicit invalidation, and the risk of serving another user's data is severe. Use the loader for request-scoped batching and a real cache layer with keys that include the user and the query shape.
Related
Pagination and the connection pattern Federation, caching and next steps
Last refreshed 2026-09-18.