Skip to content

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.

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
Resolving authors one post at a time produces one query per post — the N+1 explosion.

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);

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.

JavaScript

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.)

A resolver that queries the database per field

Symptoms:

  • Post.author resolver runs db.findUser(post.authorId) every time
  • 100 posts means 100 separate database queries
  • Fix: DataLoader.load(post.authorId) — batched into a single query

A DataLoader shared across requests

Symptoms:

  • one DataLoader is created at server startup and reused for every request
  • the per-request cache breaks — user A sees user B’s data
  • Fix: create a fresh DataLoader per request, inside the context factory

💡 Real-world examples

Facebook (DataLoader’s creator):

  • DataLoader traces back to Facebook’s internal “Loader” API (written in 2010), which became part of the Ent framework before being open-sourced as the dataloader package used with graphql-js today (graphql/dataloader README)
  • the idea is batching many load() calls within one event-loop tick into a single query; how many round trips that actually saves depends on the query shape — there is no universal fixed number

A common pattern across GraphQL teams (not tied to one company):

  • DataLoader gets used for nearly every relationship field in a schema
  • a request that queries product, variant, and inventory together still ends up with one batched query per type
What causes the N+1 problem in GraphQL?
What two things does DataLoader do to fix N+1?
What must a DataLoader batch function return?