← Lab Runs in your browser
One query per post
Ask a GraphQL API for six posts and who wrote them, and it can go to the database seven times — three of those for the same author. Run it below both ways and count the trips.
What goes wrong
A page shows a list of posts with the author's name under each one. The server gets the posts in one query. Then, for each post, it asks the database for that post's author — separately. Six posts, six more queries. If one person wrote three of them, the same row is fetched three times.
This is called N+1: one query for the list, then one for every item in it. With six posts nobody notices. With a thousand, it's a thousand and one queries for one page, and every one of them is fast on its own, so none shows up as slow.
GraphQL makes this easy to write by accident. Each field is filled in by its own
small function, and the function that fills in author only ever sees
one post. It has no idea it's being called six times in a row.
Run it
The same request on both sides, against the same pretend database. The only difference is the one line that looks up the author.
{
posts {
title
author { name }
}
} One lookup per post
Each post asks for its own author.
author: (post) =>
db.getUserById(post.authorId) Queries will show up here.
Batched with DataLoader
Each post hands its author id to a loader.
author: (post, _args, ctx) =>
ctx.loaders.user.load(post.authorId) Queries will show up here.
The loader's side of it
The loader gets every id at once and must give back one answer per id, in the
same order it received them. If an author doesn't exist, that slot is
null — the list can't just come back shorter, or every author after
the gap gets attached to the wrong post.
async function batchUsers(ids) {
const rows = await db.getUsersByIds(ids); // one query
const byId = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => byId.get(id) ?? null); // same order as ids
}
// A new loader for every request, never one shared across requests.
const context = { loaders: { user: new DataLoader(batchUsers) } }; How the fix works
The loader doesn't query anything when it's asked for an author. It writes the id down and promises an answer later. All six posts ask in the same instant, so by the time the loader acts it has a list of six ids, three of them different. It sends one query for those three and hands each post its author.
Seven queries become two, and a thousand and one become two. The resolver change is one line.
The loader has to be created fresh for each request. It remembers what it already fetched, so a loader shared between requests keeps serving old data, and one user can end up reading rows fetched for another.
What this isn't
The demo doesn't run the real GraphQL library. It uses a small stand-in that calls the resolvers in the same order the real one does, and a twenty-line loader that does what DataLoader does at its core. The database is a list in memory with a delay. The query count is exact; the milliseconds are a model.
The version with the real graphql and dataloader packages
runs in Node and prints this:
[before] 7 DB queries:
SELECT * FROM posts
SELECT * FROM users WHERE id = 1
SELECT * FROM users WHERE id = 2
SELECT * FROM users WHERE id = 1
SELECT * FROM users WHERE id = 3
SELECT * FROM users WHERE id = 1
SELECT * FROM users WHERE id = 3
[after] 2 DB queries:
SELECT * FROM posts
SELECT * FROM users WHERE id IN (1, 2, 3) Batching also only fixes this one problem. A query for a thousand ids still has to be served by an index. And the same gap can sit one level lower — authors, then each author's posts, then each post's comments — where it needs its own loader.
Where this came from
Interview preparation, published. My last backend work, at Bitpanda, was on a Fastify and GraphQL API, and I wanted to be able to show this problem rather than describe it.