Cache a GraphQL Resolver with DataLoader
Wrap a N+1 resolver in a DataLoader, batch the keys within one request, and cache the per-request result so the same key is fetched once.
Task
Eliminate the N+1 queries in a GraphQL resolver by batching and caching with DataLoader per request.
When to use this
A list of 50 orders each fetches their user separately, so one GraphQL request fires 51 database queries. The team wants one batched query per request, not 51.
Prerequisites
- A GraphQL server with a per-request context
- A DataLoader instance created once per request
- A batch function that accepts an array of keys and returns an array of values
Steps
1. Create a DataLoader per request in the context so the cache does not leak across requests.
context.loaders = {
user: new DataLoader(async (ids) => {
const users = await db.users.findMany({ where: { id: { in: ids } } }));
return ids.map((id) => users.find((u) => u.id === id));
}),
};
Expected output: A loader that batches every load call within a tick into one findMany query.
2. Use the loader in the resolver instead of fetching the user directly.
orders: { user: (order, _, ctx) => ctx.loaders.user.load(order.userId) },
Expected output: A list of 50 orders results in one database query, not 50.
3. Verify the query count with a test that counts database calls.
vitest run --testNamePattern "loads users in one query"Expected output: The test asserts the query count is one for a list of any length, so the N+1 cannot regress.
Troubleshooting
Problem
The cache leaks across requests
Solution
Create the DataLoader in the per-request context, never at module scope; a module-scoped loader serves stale data across users.
Problem
The batch function returns values in the wrong order
Solution
DataLoader expects results in the same order as the input keys; map the results back to the key order explicitly.
Limitations
- DataLoader caches per request only; a longer cache needs a separate layer with a TTL.
- The batch function must return one result per key, including null for missing rows, or the cache is inconsistent.
Batch within a request
DataLoader collects every load call within a tick and sends one batch function call. A list of 50 orders that each load their user becomes one findMany, not 50.
context.loaders = {
user: new DataLoader(async (ids) => {
const users = await db.users.findMany({ where: { id: { in: ids } } });
return ids.map((id) => users.find((u) => u.id === id));
}),
};
Cache per request, not per process
Create the loader in the per-request context. A module-scoped loader caches across requests and serves one user’s data to another. The per-request cache dies with the request, which is exactly what you want.
orders: { user: (order, _, ctx) => ctx.loaders.user.load(order.userId) }
Expected outcome
A list of 50 orders fires one database query, the same key is fetched once per request, and a test asserts the query count stays at one. The loader is recreated every request, so the cache never leaks across users.