GraphQL N+1 and DataLoader
What it is
GraphQL resolves a query field by field. A query for 50 orders, each with its
customer, executes the orders resolver once and the Order.customer resolver
50 times, because a resolver runs per parent object. If that resolver issues a
database query, you have made 51 queries where one join would have done. That is
N+1.
DataLoader is the standard fix: a per-request object that collects the keys requested during a tick of the event loop, dispatches them to a batch function in one call, and distributes the results back to the individual promises.
Two things it is commonly confused with. It is not a cache in the ordinary sense, although it memoises within a request; the point is batching. And it is not specific to GraphQL: the same pattern applies anywhere a framework calls you once per item.
The distinguishing feature of GraphQL's version of N+1 is that the client
chooses the shape. In REST you know at design time that /orders?expand=customer
triggers the join. In GraphQL the client can request any nesting depth, so the
query that causes 51 database round trips today may be a query nobody has written
yet.
The problem it solves
The naive fix, "just do a join in the top-level resolver", does not work, because the top-level resolver does not know what the client asked for. It might return orders alone or orders with customers, shipments and line-item products. Encoding every combination into the root resolver reinvents REST endpoints and defeats the reason for adopting GraphQL.
DataLoader lets each resolver stay ignorant and local (Order.customer just says
"give me customer X") while the batching happens underneath. That separation is
the whole design.
Mechanics
The batch function contract
// The two hard rules are in the comments. Getting either wrong produces
// data corruption, not an error.
const customerLoader = new DataLoader(async (customerIds) => {
const rows = await db.query(
'SELECT * FROM customers WHERE id = ANY($1)', [customerIds]
);
// RULE 1: return an array the SAME LENGTH as `customerIds`, in the SAME
// ORDER. The database returns rows in whatever order it likes, and omits
// rows for ids that do not exist. Both break positional matching.
const byId = new Map(rows.map(r => [r.id, r]));
// RULE 2: a missing key gets null or an Error at its position, never a
// silently shorter array. A shorter array shifts every result after the
// gap onto the wrong parent.
return customerIds.map(id => byId.get(id) ?? null);
});
Those two rules are where the bugs live. A batch function that returns rows in database order attaches customer 7's data to order 3, and nothing throws.
Where the loader lives
// Per request. NEVER a module-level singleton.
app.use('/graphql', (req, res) =>
createHandler({
schema,
context: () => ({
user: req.user,
loaders: {
customer: new DataLoader(batchCustomers),
// Scope the loader to the viewer where the data is access-controlled,
// or the memoisation leaks one user's rows to another.
ordersByCustomer: new DataLoader(ids => batchOrders(ids, req.user)),
},
}),
})(req, res)
);
// The resolver stays trivial and local. It has no idea batching exists.
const resolvers = {
Order: {
customer: (order, _args, ctx) => ctx.loaders.customer.load(order.customerId),
},
};
A module-level DataLoader is a security bug, not a performance optimisation. Its memoisation cache would persist across requests and across users, so user A's authorised lookup becomes user B's unauthorised hit. This is the single most important operational rule about DataLoader and the thing to say first if asked about caching.
Java and Netflix DGS
@DgsDataLoader(name = "customers")
public class CustomerDataLoader implements MappedBatchLoader<String, Customer> {
private final CustomerRepository repo;
// MappedBatchLoader returns a Map, which sidesteps the ordering rule
// entirely: absent keys simply have no entry. Prefer it over BatchLoader
// for exactly that reason.
@Override
public CompletionStage<Map<String, Customer>> load(Set<String> keys) {
return CompletableFuture.supplyAsync(() ->
repo.findAllById(keys).stream()
.collect(toMap(Customer::id, identity())));
}
}
@DgsComponent
public class OrderDataResolver {
@DgsData(parentType = "Order", field = "customer")
public CompletableFuture<Customer> customer(DgsDataFetchingEnvironment env) {
DataLoader<String, Customer> loader = env.getDataLoader("customers");
Order order = env.getSource();
return loader.load(order.customerId());
}
}
DGS registers a fresh loader per request automatically, which removes the most
common footgun. MappedBatchLoader over BatchLoader is the detail worth
knowing: returning a map makes the length-and-order contract impossible to
violate.
The alternative fix: selection-set-aware resolvers
DataLoader batches, it does not join. If you know the client asked for the customer, you can fetch it in the root query:
// Look ahead at what the client selected and adjust the root query.
@DgsQuery
public List<Order> orders(DgsDataFetchingEnvironment env) {
DataFetchingFieldSelectionSet sel = env.getSelectionSet();
if (sel.contains("customer")) {
return repo.findAllWithCustomer(); // one query with a join
}
return repo.findAll(); // one query, no join
}
One query instead of two. The cost is that the root resolver now knows about its children, which is the coupling GraphQL was supposed to remove, and it does not compose past a couple of fields. Use it for the two or three hot paths where the extra round trip actually matters; use DataLoader everywhere else.
A worked example
A storefront API. Query: 50 orders, each with its customer, and each customer with their loyalty tier.
Naive. 1 query for orders, 50 for customers, 50 for tiers. 101 queries. At 2 ms each, that is roughly 200 ms of pure database round trips, serialised, before any of your own logic runs.
With DataLoader. GraphQL executes level by level, so all 50 Order.customer
resolvers run within the same tick, and their load calls collect into one batch:
Level 1: orders resolver -> 1 query (50 rows)
Level 2: 50 x Order.customer -> batched -> 1 query (WHERE id = ANY([...]))
Level 3: 50 x Customer.tier -> batched -> 1 query
─────────
3 queries
101 to 3, and the three are sequential rather than the 100 being sequential, so about 6 ms instead of 200. That is the number to quote.
Where it stops working. Add order.lineItems (10 per order) and then
lineItem.product. Level 3 now issues 500 load calls, which batch into one
query with 500 ids. Better than 500 queries, and a 500-element IN clause has its
own problems: index selectivity collapses, the query planner may switch to a
sequential scan, and some drivers cap parameter counts. The fix is
maxBatchSize, which splits one enormous query into several reasonable ones:
new DataLoader(batchProducts, { maxBatchSize: 100 });
And the deeper fix is that a client can always ask for more nesting. That is why DataLoader alone is not a complete answer, and why query complexity limiting belongs in the same conversation: score the query before executing it and reject anything above a threshold.
Production evidence
DataLoader originated at Facebook, written by Lee Byron, as the generic
version of the batching-and-caching layer their internal GraphQL infrastructure
already had. The reference implementation (graphql/dataloader) is the canonical
one and its README documents the length-and-order contract explicitly.
Netflix DGS provides @DgsDataLoader with per-request registration and both
BatchLoader and MappedBatchLoader interfaces, plus DgsDataLoaderRegistry
for the wiring. DGS is Netflix's production GraphQL framework and sits behind
their federated graph.
Apollo Federation hits the same problem at the gateway layer: resolving an
entity across subgraphs would be one _entities call per key, so the reference
implementation batches keys into a single call per subgraph per level. Same
pattern, different tier, which is good evidence that it is inherent rather than a
library quirk.
Shopify and GitHub both run large public GraphQL APIs with published complexity-based rate limits (a query is scored before execution and charged against a budget), which is the industry's admission that batching alone does not bound the cost of a client-shaped query.
The debate
The credible alternative is not to use GraphQL for the case in question. For internal service-to-service traffic, gRPC gives you a schema, deadlines, cancellation and a fixed query shape, and none of this problem exists. For a single-consumer CRUD API, REST endpoints shaped to the consumer are simpler and cacheable by HTTP infrastructure for free.
Where GraphQL earns its cost: many client-driven shapes, mobile clients where round trips and payload size matter, and aggregation across services behind one endpoint. Those are real and the pattern is worth the machinery.
Within GraphQL, the choice is DataLoader everywhere versus selection-set-aware root resolvers on hot paths. My position: DataLoader as the default because it composes and keeps resolvers local, plus look-ahead joins on the two or three paths where the extra round trip is measurably expensive, plus a complexity limit so a client cannot construct a query that batches into something enormous. All three, because each covers a case the others do not.
DataLoader is the wrong answer when the N+1 is not per-key but per-request against an API with no batch endpoint. If the downstream only serves one item per call, batching cannot help; you need a cache, a local replica, or a change to the downstream contract. Saying that unprompted shows you understand the mechanism rather than the incantation.
Follow-up Q&A
"How do you fix N+1 in GraphQL, and why doesn't caching solve it?" DataLoader: per-request, collects keys within an execution tick, dispatches one batch call, distributes results back. Caching does not solve it for three reasons. A response cache does not help a cold or unique query, and client-shaped queries are unique by construction. HTTP caching barely applies because GraphQL is a POST with the query in the body, so there is no cache key without persisted queries. And most importantly the problem is 50 requests for 50 different keys, which is a batching problem; a cache only helps with repeats of the same key.
"Why must a DataLoader be per-request?" Because it memoises. A module-level loader keeps that memo across requests and across users, so an authorised lookup by one user becomes an unauthorised cache hit for another. It also serves stale data indefinitely, since nothing invalidates it. Per-request scoping bounds both problems to the request's lifetime.
"Your batch function returns rows from the database. What can go wrong?" Two
things, both silent. The database returns rows in its own order, so positional
matching attaches the wrong record to the wrong parent. And it omits rows for ids
that do not exist, so the array is shorter than the key array and every result
after the gap shifts onto the wrong parent. The fix is to build a map from the
rows and project it back over the key array, filling missing keys with null. In
Java, MappedBatchLoader returns a map and makes the failure impossible.
"A client sends a deeply nested query and takes the service down. DataLoader
was working. What happened?" Batching bounds the number of round trips, not the
amount of work. Nesting multiplies the batch sizes: 50 orders times 10 line items
times 1 product each is 500 keys in one query, and a 500-element IN may cause
the planner to abandon the index. The fixes are maxBatchSize to split oversized
batches, query depth limiting, and complexity scoring that rejects the query
before execution. Persisted queries are the strong version: only queries you have
registered can be executed at all.
"How does this change under Apollo Federation?" The gateway resolves entities
by calling each subgraph's _entities resolver with a set of keys, so batching
moves to the gateway and the subgraph receives a list rather than a single key.
You still need DataLoader inside each subgraph for its own N+1, and you now have a
second batching layer to reason about. The failure mode people hit is a subgraph
whose _entities resolver loops over the keys and issues one query each, which
reintroduces N+1 one level up where it is harder to see.
Common misconceptions
The most common is that DataLoader is a cache. It memoises within one request, which is a side effect of the promise map, but its purpose is batching. Treating it as a cache leads directly to the module-level singleton, which is a data-leak bug.
The second is that N+1 is a GraphQL problem. It is a resolver-per-item problem, and ORMs with lazy loading have had it for two decades. What GraphQL adds is that the client decides the shape, so you cannot enumerate the bad cases in advance.
The third is that batching makes the query cost bounded. It bounds round trips. Nesting still multiplies the work, which is why complexity limiting belongs alongside it.
Interview delivery note
Say this: "DataLoader. It's created per request, collects keys during an execution tick, calls one batch function, and hands results back to the individual promises. Caching doesn't solve it because the problem is 50 lookups for 50 different keys, which is batching, and because GraphQL is a POST so HTTP caching doesn't apply without persisted queries. Two implementation details matter: the batch function must return results in key order with nulls for misses, and the loader must never be a module-level singleton, because its memoisation would leak one user's data to another."
The depth signal is the ordering contract and the per-request security point. Almost everyone can name DataLoader. Far fewer volunteer that a shared loader is a cross-user data leak, or that the batch function returning database-ordered rows silently misattributes records.
Further reading
graphql/dataloaderREADME, for the batch-function contract and the caching semantics.- Netflix DGS documentation on
@DgsDataLoader,MappedBatchLoaderandDgsDataLoaderRegistry. - Apollo Federation specification, the
_entitiesresolver and entity-key batching at the gateway. - GitHub's and Shopify's public GraphQL API rate-limit documentation, for complexity scoring as the complement to batching.