Stop GraphQL N+1 with DataLoader Batching, Not More Resolvers
A GraphQL query for 100 posts with authors can become 101 database queries. DataLoader batches those loads into a single bulk fetch per tick and caches repeats within the same request.
11 Sept 2026, 18:44 UTC

A GraphQL query for 100 posts with their authors can quietly turn into 101 database round trips. The top-level posts resolver runs once, then the author field resolver runs once per post. That is the N+1 problem: one query for the list, N queries for the nested relation.
The useful takeaway is to batch those N individual loads into one bulk load per request tick and cache repeated loads within the same request. DataLoader provides both batching and per-request caching without changing the shape of your schema.
Where N+1 hides in GraphQL resolvers
GraphQL executes fields depth-first. A resolver for Post.author typically receives a post object and loads the author by ID. If the client asks for 100 posts, the engine calls that resolver 100 times, often in the same event loop turn.
Without coordination each call issues its own SELECT. The cost scales with list size and is invisible from the schema alone.
How DataLoader batches in one execution tick
DataLoader collects requests made during a single tick of the JavaScript event loop and dispatches them together. It also memoizes results for the lifetime of the loader instance.
Batching means you write a function that accepts an array of keys and returns an array of values in the same order. Caching means a second load(id) for the same id in the same request returns the previously fetched value without hitting the data source.
Loaders are meant to be per-request. Creating one loader per HTTP request prevents data leaking between users and avoids stale reads. Persisting a loader across requests can cause memory leaks and stale data.
Worked example: per-request user loader in Apollo Server
Create the loader in request context so each request gets a fresh cache.
// context.js
function createLoaders({ dbClient }) {
const userLoader = new DataLoader(async (userIds) => {
// userIds is an array of keys collected in this tick
// Replace with your data source. Must support bulk retrieval.
const users = await dbClient.users.findMany({
where: { id: { in: userIds } }
});
// Build a map for O(1) lookup and preserve order
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) ?? null);
});
return { userLoader };
}
Use the loader in the resolver instead of a direct fetch.
// resolvers.js
export const resolvers = {
Post: {
author: (parent, args, { loaders }) => {
// parent.authorId is known from the Post row
return loaders.userLoader.load(parent.authorId);
}
}
};
Where to run: in a Node.js service hosting your GraphQL server. Required permissions are read access to the underlying data source used in the batch function. Risks include returning values out of order or with a different length than the input keys, which will break DataLoader's contract.
Trade-offs and limits
Batching only helps if the data source supports bulk retrieval, such as an IN clause or a batch API. If the source only supports single fetches, DataLoader will not reduce round trips.
Over-fetching is a common side effect. The batch function often loads whole rows while the GraphQL query requested only a subset of fields. Consider field-level loaders or a selection-set aware data layer when this becomes costly.
Per-request caching is a feature, not a bug. Do not reuse loaders across requests. Do not enable global caching unless you add explicit invalidation.
Check it works
Enable SQL logging for the database and compare query counts before and after adding the loader for the same query. Use Apollo Server tracing to count resolver executions.
Verify the batch function receives an array of keys and returns an array of the same length, with nulls where appropriate. That contract is what keeps DataLoader stable.
Start with one high fan-out relation, make the loader per-request, and measure. Add more loaders only where N+1 is observed.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.