What is the GraphQL N+1 problem, and how does DataLoader (batching + caching) fix it?
One GraphQL query can silently trigger hundreds of database calls when nested resolvers run independently — DataLoader batches and caches them away.
LEARN BY DOINGPut your intuition to work3 steps
What is the GraphQL N+1 problem, and how does DataLoader (batching + caching) fix it?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#A query that looks innocent
Consider a GraphQL query that fetches a list of blog posts and, for each one, the name of its author:
query {posts {idtitleauthor {name}}}
Nothing about this query looks dangerous. It reads like a single request, and from the client's perspective it is. But on the server it can turn into dozens or hundreds of individual database round trips, one for every post in the list. That gap between "looks like one query" and "runs like N+1 queries" is the single most common performance bug in production GraphQL APIs.
#Why the executor does this by design
GraphQL resolves a query field by field, and every field has its own resolver function. When a field returns a list, the child resolvers for that list's sub-fields are invoked once per item, independently, with no built-in awareness of their siblings. A naive schema for the query above looks like this:
type Author {id: ID!name: String!}type Post {id: ID!title: String!author: Author!}type Query {posts: [Post!]!}
And a naive resolver implementation:
const resolvers = {Query: {posts: () => db.query("SELECT id, title, author_id FROM posts"),},Post: {// This runs once PER post returned above — that is the N+1.author: (post) => db.query("SELECT id, name FROM authors WHERE id = $1",[post.author_id]),},};
If posts returns 50 rows, the Post.author resolver fires 50 separate SELECT statements against the authors table — one query to fetch the list (the "1"), plus N queries to resolve each item's nested field (the "N"). Nest one more level (comments on each post, each comment's author) and the multiplier compounds.
#The fix: batch and cache with DataLoader
DataLoader is a small utility (originally built at Facebook alongside GraphQL.js) that sits between your resolvers and your data access layer. It does two things:
- Batching — within a single tick of the event loop, it collects every
.load(key)call made anywhere in the request and hands them to one user-supplied batch function as a single array of keys, instead of calling your data layer once per key. - Caching — within the lifetime of one request, calling
.load(7)twice returns the same in-flight or cached promise instead of triggering a second query, which also means the executor's per-item resolver calls naturally de-duplicate.
A batch function has one contract: given an array of keys, return a promise for an array of values of the same length, in the same order.
const DataLoader = require("dataloader");function createAuthorLoader() {return new DataLoader(async (authorIds) => {// authorIds might be [7, 3, 7, 12] — DataLoader has already// deduplicated internally before calling this function.const rows = await db.query("SELECT id, name FROM authors WHERE id = ANY($1)",[authorIds]);const byId = new Map(rows.map((row) => [row.id, row]));// Order and length must match the input keys exactly.return authorIds.map((id) => byId.get(id) ?? null);});}
Wire a fresh loader into the request context, and point the resolver at it instead of the raw data layer:
const server = new ApolloServer({typeDefs,resolvers,context: () => ({authorLoader: createAuthorLoader(), // one new loader per request}),});const resolvers = {Query: {posts: () => db.query("SELECT id, title, author_id FROM posts"),},Post: {author: (post, _args, context) => context.authorLoader.load(post.author_id),},};
Nothing changed about how the executor calls the author resolver — it still fires once per post. What changed is that each call now registers a key with the loader instead of hitting the database directly. DataLoader waits until the current tick finishes, collects the accumulated keys (deduplicated: [7, 3, 12] instead of [7, 3, 7, 12]), and calls the batch function exactly once. Fifty posts by a handful of distinct authors now costs two queries total, not fifty-one.
#Why this must be request-scoped
#Where the same idea shows up elsewhere
The N+1 pattern is not specific to GraphQL — it is what happens whenever a loop triggers an I/O call per iteration instead of one call for the whole batch. ORMs hit the same wall with lazy-loaded associations (Rails' bullet gem and Django's select_related/prefetch_related exist for exactly this reason), and REST APIs get it too when a list endpoint fans out to N detail calls. GraphQL just makes the problem more likely by design, because its field-by-field resolution model actively encourages writing resolvers as if each one were independent.
| Approach | Queries for 50 posts, 10 distinct authors | Cache scope |
|---|---|---|
| Naive resolver | 51 (1 + 50) | none |
| DataLoader | 2 (1 + 1 batched) | per request |
🧠 Check your understanding
0/3 · 0/3 answered1. Why does the naive Post.author resolver cause an N+1 problem?
2. What must a DataLoader batch function guarantee about its return value?
3. Why should a new DataLoader instance be created per request instead of reused across requests?