Concept
The beginner framing: GraphQL's resolver-per-field execution model, genuinely useful for flexibility, has a sharp edge that catches nearly everyone the first time they build a real GraphQL API backed by a database: the N+1 problem, the single most commonly asked GraphQL interview question.
The problem, precisely
query {
users {
id
posts { title }
}
}query {users {idposts { title }}}
The users resolver runs once, fetching all users in a single query, say, 3 users come back.
The users resolver runs once, fetching all users in a single query. But GraphQL invokes the posts resolver once per user, with each invocation having no awareness the others are happening, so each one fires its own separate database query. 3 users means 1 (for the list) + 3 (one per user) = 4 total queries. This is bad enough to notice at 3 users; at 1,000 users, it's 1,001 queries, a genuine production incident, not a theoretical concern.
The fix: DataLoader, batching AND automatic deduplication
const postsLoader = new DataLoader(async (userIds) => {
return batchFetchPostsByAuthorIds(userIds); // ONE query for ALL requested ids
});
// resolver code barely changes:
const resolvers = {
User: {
posts: (user) => postsLoader.load(user.id),
},
};const postsLoader = new DataLoader(async (userIds) => {// ONE query for ALL requested ids, not one per idreturn batchFetchPostsByAuthorIds(userIds);});
A DataLoader wraps a batch-fetching function, instead of fetching one id at a time, its batch function receives an ARRAY of ids and returns results for all of them in one call.
Confirmed by running this exact scenario: .load() doesn't fetch immediately, it queues the requested key and returns a pending promise. All .load() calls issued within the same batching window are collected, then the batch function fires once, with an array of all the collected keys. Confirmed precisely: 3 .load() calls, including one duplicate key, produced exactly one batch call, and the batch function received the keys with the duplicate automatically removed, DataLoader deduplicates identical in-flight requests, not just batches distinct ones.
The batching window is tied to the event loop, not a timer
loader.load(1); // synchronous
loader.load(2); // synchronous, SAME tick as the line above
setTimeout(() => {
loader.load(3); // a LATER tick, a separate macrotask
}, 10);Confirmed by running this exact code: .load(1) and .load(2), called synchronously back-to-back, batched into one call with keys [1, 2]. .load(3), issued inside a setTimeout callback, triggered a separate, second batch call entirely, confirmed via the batch counter reaching exactly 2. DataLoader's batching window isn't a fixed duration, it flushes once the current synchronous execution completes (technically, on the next microtask tick), so calls collected within one execution context of resolvers running for a single GraphQL operation batch together naturally, while calls separated by a macrotask boundary (like setTimeout, or genuinely separate requests) don't.
The critical operational gotcha: one DataLoader instance per request
// ❌ WRONG, a single shared loader across ALL requests
const postsLoader = new DataLoader(batchFn);
app.use("/graphql", graphqlServer({ context: () => ({ postsLoader }) }));
// ✅ CORRECT, a FRESH loader created for EACH incoming request
app.use("/graphql", graphqlServer({
context: () => ({ postsLoader: new DataLoader(batchFn) }),
}));