← Recipes

阅读中文版本

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.

~30 mingraphqldataloaderperformance

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.