N+1 and DataLoader
You now know how to model relationships and page through lists. The final piece is keeping a relationship-heavy query fast. The trap that catches almost everyone is the N+1 problem, and the standard cure is a tiny library called DataLoader. Understand both and your graph stays quick no matter how deeply clients traverse it.
The N+1 problem
Section titled “The N+1 problem”GraphQL resolves fields independently. When a query asks for a list of posts and, for each post, its author, the engine runs the author resolver once per post. Fetch a page of 50 posts and the server does:
- 1 query to load the 50 posts, then
- N = 50 separate queries to load each post’s author.
That is 51 round trips — N + 1 — where one well-designed query could have done it in two. The cost is invisible in development with three rows of seed data and catastrophic in production with real traffic. It is not a bug in your resolvers; it is the natural consequence of resolving each field on its own.
flowchart TD
Q["Query: posts { author }"]
Q -->|"1 query"| Posts["Load N posts"]
Posts --> A1["author resolver: post 1"]
Posts --> A2["author resolver: post 2"]
Posts --> A3["author resolver: post 3"]
Posts --> AN["author resolver: post N"]
A1 -->|"query"| DB["Data source"]
A2 -->|"query"| DB
A3 -->|"query"| DB
AN -->|"query"| DB DataLoader: batch and cache
Section titled “DataLoader: batch and cache”DataLoader solves this with two ideas:
- Batching. Instead of firing a load immediately, DataLoader collects every key requested during a single tick of the event loop, then calls your batch function once with the whole array of keys. Your batch function turns 50 individual “load author
aX” calls into one “load authors[a1, a2, …]” call. - Caching. Within one request, asking for the same key twice returns the cached result — the batch function never sees a duplicate.
You give DataLoader a batchFn(keys) that takes an array of keys and returns an array of results in the same order. Each resolver just calls loader.load(key) and awaits a promise; DataLoader handles the coalescing.
import DataLoader from 'dataloader';
// Called ONCE per tick with all collected keys.const authorLoader = new DataLoader(async (ids: readonly string[]) => { const rows = await db.authorsByIds(ids); // one batched query return ids.map((id) => rows.find((r) => r.id === id)!);});
// In a resolver: just load by key — batching is automatic.const author = (post) => authorLoader.load(post.authorId);Counting the calls, run for real
Section titled “Counting the calls, run for real”The runner below resolves the same query two ways and counts how many times the loader function fires. The unbatched version calls the data source once per post; the batched version collects the keys and calls it once. Press Run and compare the two call counts in the output.
The output makes the difference concrete: with four posts the unbatched path calls the author lookup 4 times, while the batched loader’s batchFn fires once — the keys were collected during a single tick and resolved together. In production that batch function would be one WHERE id IN (…) query instead of dozens. (The real dataloader package, importable the same way via await import('https://esm.sh/dataloader@2'), does exactly this with added caching and edge-case handling; the loader above is a minimal version to show the mechanism.)