Solving N+1 with DataLoader
The first GraphQL endpoint I shipped looked clean in the schema and returned correct data. Then someone queried 50 posts with their authors and comments, and Postgres logged 151 queries. One for posts, fifty for authors, fifty for comments. The resolvers were textbook — each field fetched its own data — and that was exactly the problem. DataLoader fixed it without rewriting the schema or denormalizing the API.
How N+1 sneaks in
GraphQL resolves fields depth-first. A query like:
query {
posts(limit: 50) {
title
author { name }
comments { body }
}
}
Triggers:
postsresolver →SELECT * FROM posts LIMIT 50(1 query)authorresolver × 50 →SELECT * FROM users WHERE id = ?(50 queries)commentsresolver × 50 →SELECT * FROM comments WHERE post_id = ?(50 queries)
Total: 101 queries. Scale the list to 500 and you're drowning.
The resolver code looks innocent:
Post: {
author: (post) => db.users.findById(post.authorId),
comments: (post) => db.comments.findByPostId(post.postId),
}
Each call is correct in isolation. Together they're a performance disaster.
DataLoader mechanics
DataLoader wraps a batch function and a per-request cache:
const DataLoader = require('dataloader');
function createLoaders(db) {
return {
userById: new DataLoader(async (ids) => {
const users = await db.users.findByIds(ids);
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id) ?? null);
}),
};
}
Key rules:
- Batch function receives an array of keys — return results in the same order
- One loader per entity type — don't mix users and posts in one loader
- New loaders per request — attach to
context, not a global singleton
The resolver becomes:
Post: {
author: (post, _, { loaders }) => loaders.userById.load(post.authorId),
}
Fifty load() calls in the same event loop tick become one SELECT WHERE id IN (...).
Per-request scoping
Attach loaders to GraphQL context in your server setup:
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => ({
loaders: createLoaders(db),
userId: req.user?.id,
}),
});
For Express + graphql-http:
app.all('/graphql', createHandler({
schema,
context: (req) => ({ loaders: createLoaders(db) }),
}));
Never reuse loaders across requests — the cache would serve one user's data to another.
Batching across different resolvers
DataLoader deduplicates within a batch. If two posts share the same author, userById.load(sameId) hits the cache after the first load. You get one DB row, two resolver returns.
For nested lists (comments per post), use a composite key loader:
commentsByPostId: new DataLoader(async (postIds) => {
const rows = await db.comments.findByPostIds(postIds);
const grouped = groupBy(rows, 'postId');
return postIds.map(id => grouped[id] ?? []);
}),
One query: SELECT * FROM comments WHERE post_id IN (...).
Java / Spring GraphQL
The Java DataLoader library works the same way:
@Bean
DataLoaderRegistry dataLoaderRegistry(UserRepository users) {
DataLoader<Long, User> userLoader = DataLoader.newMappedDataLoader(
ids -> users.findByIds(ids).thenApply(users ->
ids.stream().collect(toMap(id -> id, users::get)))
);
return DataLoaderRegistry.newRegistry()
.register("userById", userLoader)
.build();
}
Spring GraphQL integrates via @BatchMapping:
@BatchMapping
public Map<Post, User> author(List<Post> posts) {
Set<Long> ids = posts.stream().map(Post::authorId).collect(toSet());
Map<Long, User> users = userRepository.findByIds(ids);
return posts.stream().collect(toMap(p -> p, p -> users.get(p.authorId())));
}
Spring batches the list of posts automatically — same outcome, less boilerplate.
When DataLoader isn't enough
DataLoader fixes resolver-level N+1. It doesn't fix:
- Deep joins you should push to SQL — sometimes a single query with JOINs beats batched loaders
- Cross-service N+1 — batching HTTP calls to microservices needs a different pattern (GraphQL federation with
@requiresbatching, or a BFF aggregation layer) - Pagination + nested fields — cursor pagination with nested lists needs careful loader design to avoid loading entire child tables
Profile first. Log query counts per request. DataLoader is the default fix for relational N+1, not the only tool.
Monitoring DataLoader efficiency
Track batch sizes in production to verify batching is working:
const loader = new DataLoader(async (ids) => {
metrics.histogram('dataloader.batch_size', ids.length, { loader: 'userById' });
const users = await db.users.findByIds(ids);
// ...
});
If p50 batch size is 1, your resolvers aren't batching — likely an async timing issue where each resolver awaits before the next fires. Consider @graphql-tools/batch-execute or ensuring all resolvers in a level execute before any awaits resolve.
Common production mistakes
Teams get n plus one dataloader wrong in predictable ways:
- Skipping failure-mode rehearsal — run a game day or fault injection exercise before peak traffic, not after the first outage.
- Missing correlation context — every error path should carry request, trace, or tenant identifiers so incidents are debuggable.
- Optimizing for demo, not steady state — load tests, cache warm-up, and cold-start paths matter more than local dev latency.
- Undocumented trade-offs — if you chose speed over strict correctness (or vice versa), write that down for the next engineer.
GraphQL APIs for n plus one dataloader melt down under nested queries without depth limits, N+1 resolvers hit the database per field, and schema deprecation has no usage telemetry.
Debugging and triage workflow
When n plus one dataloader misbehaves in production, work top-down instead of guessing:
- Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
- Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
- Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
- Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
- Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
- Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.
Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.
Resources
- DataLoader GitHub (JavaScript) — reference implementation and API docs
- GraphQL DataLoader specification — batching semantics and caching rules
- Spring GraphQL @BatchMapping — Java batch resolver support
- Shopify GraphQL Design Tutorial — schema patterns that reduce over-fetching
Frequently asked questions
What causes the N+1 problem in GraphQL?
When a list field resolver runs a separate database query for each item's nested field, you get 1 query for the list plus N queries for each child — hence N+1. GraphQL's field-by-field resolution model makes this easy to accidentally introduce because each resolver is independent and unaware of sibling calls.
How does DataLoader solve N+1?
DataLoader collects individual load requests during a single tick of the event loop, batches them into one call (e.g., SELECT WHERE id IN (...)), and caches results for the lifetime of the request. Resolvers stay simple — they call loader.load(id) — while the loader handles batching and deduplication.
Should DataLoader cache persist across requests?
No. Create a new DataLoader instance per request to avoid leaking data between users. The in-request cache prevents duplicate loads within the same query (e.g., the same author referenced twice), but it must be discarded when the request completes.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →