GraphQL in Production · Performance and Caching

Resolvers, N+1 and DataLoader

Batch and cache data loading per request.

Resolvers run per field, so naive resolvers query the database once per parent object: listing 50 orders with customer triggers 50 customer queries (N+1). DataLoader collects all keys requested during one tick of execution and calls a batch function once (WHERE id IN (...)), also caching per request. Create loaders per request (in the context) so caches never leak between users.

import DataLoader from "dataloader";

export const createContext = async ({ req }) => ({
  user: await authenticate(req),
  customerLoader: new DataLoader<string, Customer | null>(async (ids) => {
    const rows = await db.customer.findMany({ where: { id: { in: [...ids] } } });
    const byId = new Map(rows.map((c) => [c.id, c]));
    return ids.map((id) => byId.get(id) ?? null);        // same order as keys!
  }),
});

const resolvers = {
  Order: { customer: (order, _args, ctx) => ctx.customerLoader.load(order.customerId) },
};

Common pitfalls

  • Global (cross-request) DataLoaders leaking data between users.
  • Batch functions returning results in a different order than keys.
  • Assuming DataLoader fixes deeply nested fan-out without cost limits.

Key points

  • Resolvers per field ⇒ N+1 risk
  • DataLoader batches + caches per request
  • Return results aligned with keys

Further reading: DataLoader

Practice questions

Why must DataLoaders be created per request?

Answer

Their cache would otherwise serve one user's data to another and grow unbounded; per-request loaders scope batching and caching to a single operation and user.

What contract must a DataLoader batch function satisfy?

Answer

Return an array of the same length and order as the input keys, with null (or an Error) for missing keys.

Open in the app to mark lessons complete, take notes and track your progress.