← Recipes

阅读中文版本

Fix an N+1 Query Problem in a GraphQL Resolver

Detect N+1 resolver queries with request-scoped query logging, batch them with DataLoader, and prove the query count drops from 1+N to exactly 2.

~45 minapigraphqlperformance

Task

Measure the database query count of a GraphQL field, replace per-entity loads with a batched DataLoader, and verify the count collapses.

When to use this

A list query returning 50 orders fires 51 SQL statements. The API takes seconds, the database pool saturates, and the delay grows linearly with page size.

Prerequisites

  • A GraphQL server such as Apollo Server or GraphQL Yoga
  • Request-scoped logging or a query counter in the resolver context
  • DataLoader available in the project

Steps

1. Count queries for one request before changing anything.

const queryCount = { n: 0 };
const server = new ApolloServer({
  context: () => ({ queryCount }),
});
// in the SQL layer:
queryCount.n += 1;

Expected output: Resolving 50 orders logs 51 queries: one for the list plus one per nested customer, the signature of N+1.

2. Wrap the per-entity fetch in a DataLoader keyed by id.

const customerLoader = new DataLoader(async (ids) => {
  const rows = await db.customers.findMany({ where: { id: { in: ids } } });
  const byId = new Map(rows.map((r) => [r.id, r]));
  return ids.map((id) => byId.get(id) ?? null);
});

Expected output: The loader is created per request in context, so batching never leaks identities across requests.

3. Point the resolver at the loader.

Order: {
  customer: (order, _, { loaders }) => loaders.customer.load(order.customerId),
},

Expected output: During one response, all customer loads queue behind a single database round trip scheduled on the next tick.

4. Re-run the counter and confirm the collapse.

curl -s -X POST http://localhost:4000 -H "content-type: application/json" -d '{"query":"{ orders(limit: 50) { id customer { name } } }"}'

Expected output: The same request now logs exactly 2 queries, and the response time drops from seconds to tens of milliseconds.

Troubleshooting

Problem

Query count barely improves

Solution

The loader was created once at module scope, so batches from different requests interleave and caching spans users; build a fresh loader per request in context instead.

Problem

Null results disappear from the list

Solution

The batch function returned an array misaligned with the input ids; always map results back to the requested id order and use null for misses.

Problem

Batching breaks ordering guarantees

Solution

DataLoader preserves input order, not database order; sort in the resolver after loading if the field contract requires a specific order.

Limitations

  • Batching only helps when many entities resolve in one response; single-entity queries gain nothing.
  • Loaders cache per request by key, so mutations inside the same request can serve stale values unless you clear the cache explicitly.

Count first, batch second

N+1 is invisible in the schema and obvious in the database log. Before touching code, count queries per request: resolve a list of N entities and read the counter. If it reads N+1, every nested field is doing its own fetch. DataLoader fixes this by collecting all keys requested during one tick and issuing a single IN query, but only the counter can prove the fix landed.

const customerLoader = new DataLoader(async (ids) => {
  const rows = await db.customers.findMany({ where: { id: { in: ids } } });
  const byId = new Map(rows.map((r) => [r.id, r]));
  return ids.map((id) => byId.get(id) ?? null);
});

One loader per request, never per process

A module-scoped loader batches across concurrent requests and caches results for different users, which is both a leak and a correctness bug. Construct loaders in context so each request gets its own batching window and cache, and dispose them when the response ends.

Assert the ceiling, not the vibes

Keep the query counter in the test suite and assert that the worst query in your schema stays under a fixed ceiling, such as 5 queries per request. That ceiling turns N+1 regressions into red tests the moment someone adds a chatty resolver, instead of a slow ramp you discover in production dashboards months later.