Resolvers, Context & DataLoader: The N+1 Is Always Your Fault
The N+1 problem is not a GraphQL weakness — it is the default behavior of naive resolver implementation. DataLoader is not an optimization; it is the minimum correct implementation of any resolver that fetches from a shared data source.
GraphQL Backend & API Design
Resolvers, Context & DataLoader: The N+1 Is Always Your Fault
You own the schema and the resolvers — the schema is a public contract you can never silently break, and every resolver is a performance commitment you make on every query. In Part 1, you designed a schema where User.orders is a graph traversal relationship. That decision looks elegant in SDL. What you may not have noticed is that you made a performance commitment with every field you put on User: for every user returned by the users query, the GraphQL execution engine will call User.orders once. In production, with 50 users per page, that is 50 sequential database queries — plus however many Order.items resolvers fire after that. The N+1 problem is not a GraphQL bug you can avoid by writing careful queries. It is the default execution behavior of the runtime, and the only escape is DataLoader.
This is Part 2 of the GraphQL Backend & API Design series. It assumes you have read Part 1 (Schema Design: The SDL as a Domain Contract) and understand the schema structure we are now implementing resolvers for.
1. How the Resolver Execution Chain Actually Works
Before fixing N+1, you must understand exactly why it happens. The GraphQL execution model is field-by-field, not query-by-query.
1.1 The Execution Tree
When the runtime executes this query:
It resolves fields in this exact order:
Query.users— runs once, returns[User](e.g. 10 users)User.id,User.name— runs once per user (10×2 = 20 field calls, trivial — no DB)User.orders— runs once per user (10 calls, each hits the DB)Order.id,Order.total— trivial field resolvesOrder.items— runs once per order (if each user has 5 orders = 50 calls)OrderItem.product— runs once per item (potentially hundreds of calls)
The reason this is invisible in development: your local PostgreSQL responds in under 1ms per query. The N+1 pattern produces 50ms total in development and 2,500ms in production — a 50× performance cliff that only appears under real network conditions.
The N+1 problem does not appear in unit tests (you mock the DB), does not appear in integration tests (your test DB has 2 rows), and does not appear in staging (your staging dataset has 10 rows). It only appears in production with real data volumes. Engineers ship it constantly precisely because no test catches it.
2. DataLoader: The Minimum Correct Implementation
DataLoader is not a caching layer you add when performance becomes a problem. It is the correct implementation of any resolver that reads from a shared data source. A resolver that reads from a database inside a list field without DataLoader is incorrect by default.
2.1 The batchLoadFn Contract
DataLoader works by collecting individual key requests within a single event loop tick and issuing them as a single batched query.
The transformation in database queries:
The key order invariant is non-negotiable. DataLoader maps each key to the value at the same index in the returned array. If your batchLoadFn returns values in a different order (e.g., sorted by ID from the DB), each User.orders call receives the wrong orders. This is a silent data corruption bug — no error thrown, no test catches it, and users see each other's data.
2.2 DataLoader v2 API: maxBatchSize and batchScheduleFn
DataLoader v2 introduced two important parameters for high-throughput environments:
3. The Context Object: Per-Request Lifecycle
DataLoader instances must be created fresh for every request. This is the most common DataLoader production bug: creating a module-level singleton DataLoader that is shared across all requests.
3.1 The Singleton Anti-Pattern
3.2 The Correct Per-Request Context Factory
The context object is the per-request service locator. Think of it as the GraphQL equivalent of a dependency injection container that lives exactly as long as one HTTP request — it is created at the start of the request and garbage collected when the response is sent. Nothing in the context should outlive a single request.
3.3 Pothos DataLoader Plugin
If you are using Pothos v4, the DataLoader plugin provides type-safe DataLoader integration with automatic key type inference:
4. Resolver Error Semantics: null vs. throw
Two different failure conditions exist for resolvers, and they have different correct responses:
4.1 null — The Entity Does Not Exist
4.2 throw GraphQLError — Something Failed While Fetching
| Condition | Correct Response | Client Receives |
|---|---|---|
| Entity legitimately absent | return null |
{ data: { user: null } } — no error |
| Authentication missing/expired | throw GraphQLError({ extensions: { code: 'UNAUTHENTICATED' } }) |
{ data: null, errors: [{ extensions: { code: 'UNAUTHENTICATED' } }] } |
| Insufficient permissions | throw GraphQLError({ extensions: { code: 'FORBIDDEN' } }) |
{ data: null, errors: [{ extensions: { code: 'FORBIDDEN' } }] } |
| DB error during fetch | throw GraphQLError({ extensions: { code: 'INTERNAL_SERVER_ERROR' } }) |
{ data: null, errors: [...] } |
The extensions.code field is the machine-readable contract the client error handler must key on — not the message string. Message strings are for developers reading logs. Codes are for the client's onError function routing to the correct error boundary. Handle UNAUTHENTICATED by redirecting to login. Handle FORBIDDEN by rendering an access-denied state. Handle NOT_FOUND by rendering a 404 component.
5. graphql-yoga v5 — Zero-Config DataLoader Context
graphql-yoga v5 provides a built-in context plugin that handles per-request DataLoader scoping without manual wiring:
6. Identifying N+1 in Production Traces
Once DataLoader is in place, how do you verify it's working? A DataLoader batchSize: 1 in a production trace is the N+1 signature — the DataLoader is present but its batching is not triggering.
In your APM tool (Datadog, Jaeger), look for:
batchSize: 50in DataLoader spans → batching is working ✅batchSize: 1on a DataLoader that handled 50 keys → N+1 still present ❌
The batchSize: 1 signature means the DataLoader's batch window is firing before the second resolver call registers its key. Root causes: calling await loader.load(key) instead of collecting promises and Promise.all-ing them, or calling the loader outside the resolver execution context.

Left: N+1 — one DB query per user fires sequentially, totaling 2,500ms for 50 users. Right: DataLoader — all 50 user IDs are batched into one WHERE user_id IN (...) query, totaling 55ms.

The context object is a per-request service container. DataLoader instances inside it have a request-scoped cache. Module-level DataLoader singletons share that cache across all requests — a data leakage vulnerability.
Summary
| Concept | Rule |
|---|---|
| N+1 is always your fault | A resolver that queries a database inside a list field without DataLoader executes N queries for N parent items by default — this is the execution model, not a bug. |
batchLoadFn key order |
DataLoader's batchLoadFn receives an array of keys and must return an array of values in exactly the same order — returning in a different order silently maps data to the wrong parent. |
| Per-request context | DataLoader instances must be created per-request in the context factory — module-level singleton DataLoaders share caches across requests, causing data leakage between users. |
null vs. throw |
Return null from a resolver to signal "this entity does not exist in this context"; throw GraphQLError to signal "something failed while fetching this required data." |
extensions.code |
The extensions.code field in GraphQLError is the client's machine-readable signal — emit UNAUTHENTICATED for missing/expired auth, FORBIDDEN for insufficient permissions, NOT_FOUND for missing entities. |
What's Next
Part 3 — Federation & Subgraph Architecture extends the domain contract across team boundaries: a subgraph boundary is correct only when it maps to a domain that a single team owns end-to-end. Part 3 covers @key entity references, Apollo Router vs. the deprecated Gateway, schema registry CI checks, and partial data semantics when a subgraph is unavailable.
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.