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) },
};
from strawberry.dataloader import DataLoader
async def load_customers(ids: list[str]) -> list[Customer | None]:
rows = await customers_repo.by_ids(ids)
by_id = {c.id: c for c in rows}
return [by_id.get(i) for i in ids]
async def get_context() -> dict:
return {"customer_loader": DataLoader(load_fn=load_customers)} # per request
@strawberry.type
class Order:
customer_id: strawberry.Private[str]
@strawberry.field
async def customer(self, info: strawberry.Info) -> Customer | None:
return await info.context["customer_loader"].load(self.customer_id)
@Controller
class OrderController {
@QueryMapping
List<Order> orders(@Argument int first) { return orderService.recent(first); }
// Called once with all orders in the selection: no N+1
@BatchMapping
Map<Order, Customer> customer(List<Order> orders) {
Map<String, Customer> byId = customerService.findByIds(orders.stream().map(Order::customerId).toList());
return orders.stream().collect(Collectors.toMap(o -> o, o -> byId.get(o.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.