The GraphQL N+1 Problem and Why DataLoader Is Still the Pragmatic Fix
GraphQL resolvers execute independently, so a list query with nested fields silently becomes N+1 database calls. Here's how DataLoader's batching fixes it, with a worked example and the two production mistakes to avoid.
18 Aug 2025, 12:05 UTC

You ship a GraphQL endpoint that returns a list of posts, each with its author. It works fine in development with five rows. Then production traffic arrives, someone requests 50 posts, and your database logs show 51 queries for a single request: one to fetch the posts, then one per post to fetch its author. That's the N+1 problem, and it's nearly unavoidable in a naive GraphQL server because of how resolvers work.
The fix most teams land on is DataLoader: a small batching-and-caching utility that turns 50 individual lookups into one WHERE id IN (...) query. This post explains why the problem exists, shows a concrete before/after, and covers the two mistakes that bite people in production.
Why GraphQL resolvers cause N+1 by default
In a REST endpoint you control the whole handler, so fetching posts with authors is one JOIN and you're done. In GraphQL, each field has its own resolver, and resolvers execute independently. When the query asks for posts { author { name } }, the server runs the posts resolver once, then runs the author resolver once per post. Each of those resolver invocations typically issues its own database call, because it has no natural way to know its siblings exist.
This is a structural property of the execution model, not a bug in your code. Any resolver that fetches a related record by ID will produce it.
How DataLoader fixes it
DataLoader (originally from Facebook, with ports for JavaScript, Java, Python, and others) does two things:
- Batching: calls to
load(id)made within the same execution tick are collected, and your batch function receives all the keys at once. You issue one query instead of many. - Per-request memoization: loading the same key twice in one request returns the same cached promise, deduplicating database hits and keeping object identity consistent across the response.
The batch function's contract is simple but strict: return a list of results in the same order as the keys, with null (or an error) in the position of any missing record.
A worked example
Assume a Node.js server using the dataloader package, with a SQL client exposed as db. Run this code wherever you build your per-request GraphQL context — it needs no special permissions beyond your normal database access.
Before — the N+1 version:
const resolvers = {
Post: {
author: (post) =>
db.query('SELECT * FROM users WHERE id = $1', [post.authorId])
.then(rows => rows[0]),
},
};Fifty posts means fifty SELECT ... WHERE id = $1 statements.
After — batched with DataLoader:
const DataLoader = require('dataloader');
// Build this per request, inside your context factory.
function createLoaders(db) {
return {
userById: new DataLoader(async (userIds) => {
const rows = await db.query(
'SELECT * FROM users WHERE id = ANY($1)',
[userIds]
);
const byId = new Map(rows.map((r) => [r.id, r]));
// Must return results in the same order as the input keys.
return userIds.map((id) => byId.get(id) ?? null);
}),
};
}
const resolvers = {
Post: {
author: (post, args, context) =>
context.loaders.userById.load(post.authorId),
},
};Now all 50 author resolver calls in the same tick collapse into a single WHERE id = ANY($1) query. The total for the request drops from 51 statements to 2.
How to verify it worked: enable query logging on your database (for PostgreSQL, set log_statement = 'all' temporarily, or use your driver's logging hook) and count statements for one representative query before and after the change. You should see the per-row lookups collapse into one batched statement. Don't trust the absence of errors — count the actual queries.
The two mistakes that bite in production
1. Sharing one DataLoader across requests. Because DataLoader caches, a global singleton will serve user A's cached data to user B — a correctness bug at best, a data-leak security incident at worst. Create the loaders inside your context factory so every request gets a fresh instance. If you want to see why this matters, deliberately share one loader across two requests that mutate the same row and watch the second request get the stale value.
2. Non-scalar keys without a cacheKeyFn. DataLoader caches by key equality. If your keys are objects (say, { orgId, userId } for a multi-tenant lookup), two equal-looking objects are different references and batching silently fails — you're back to N+1 with extra steps. Pass a cacheKeyFn that serializes the key deterministically.
Trade-offs and the alternatives
DataLoader is the pragmatic default, not a free lunch:
- Batching latency: the loader waits a tick to collect keys, adding a small delay. For latency-critical single-item lookups this is usually negligible, but it exists.
- Batch size limits: huge batches can exceed driver parameter limits (PostgreSQL caps at 65535 bind parameters). Most ports expose a
maxBatchSizeoption — check your library's README, since defaults and scheduling semantics (e.g.,process.nextTickvssetImmediate) vary by port. - It's not a JOIN: you still run two queries (posts, then users) where SQL could do one. Lookahead-based approaches — inspecting the
GraphQLResolveInfoselection set to generate a JOIN, as tools like join-monster do — can be more efficient, but they're significantly more complex and brittle when schemas evolve. For most teams, DataLoader's simplicity wins.
Where to start
If you have a GraphQL server in production and haven't audited it: turn on query logging, run your most common list query with a realistic page size, and count statements. Any resolver that fetches by ID inside a list is a DataLoader candidate. Wrap it in a per-request loader, re-run the same query, and confirm the statement count dropped. That thirty-minute exercise is usually the highest-value performance work a GraphQL codebase is waiting for.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.