GraphQL / Intermediate: policy, sources and clients / Lesson 9
Batching, loaders and the N+1 problem
Reduce repeated source work while preserving request isolation, key order and missing-record semantics.
On this page
Lesson goals
- Recognize resolver fanout and distinguish batching from global caching.
- Map unordered batch results back to requested key order.
Measure the actual source call pattern
A list of courses can resolve each author independently. A naive implementation makes one call for the course list and one for every author reference, even when several courses share an author. This is the N+1 pattern. It is not unique to GraphQL, but field-by-field execution makes it easy to introduce. Count source calls and key cardinality for a representative operation before choosing a remedy. A join, source-native batch endpoint or request-scoped loader may fit; there is no rule requiring a loader for every field.
Create the loader inside request scope
A DataLoader-style tool groups compatible key loads and commonly memoizes results within its lifetime. The illustrated context factory creates a new author loader for each authenticated request. The batch function still has to apply the caller’s tenant and visibility policy. A process-global singleton would retain request-dependent results and can cause data leaks or stale reads. The snippet uses the real tool’s conceptual interface but is intentionally non-runnable because no package or data source is installed or contacted by this course.
function makeContext(principal, services, DataLoader) {
return {
principal,
authorLoader: new DataLoader(keys =>
services.authors.findVisibleInKeyOrder(keys, principal))
};
}
findVisibleInKeyOrder must return one result for every requested key in the same order, including explicit missing/error outcomes.
Preserve cardinality, order and errors
A batch source may return rows in arbitrary order and omit missing records. A loader must not simply forward that array if consumers expect positions to match their keys. Build an index, then map the original key sequence back to values. Decide how missing or denied records are represented without leaking which condition occurred. Per-key failures and a batch-wide source outage may need different handling. Review the library’s cache and error semantics rather than assuming all failures are retried or all null results are harmless.
Verify mapping with a missing middle key
This offline example deliberately receives rows out of order and lacks a2. Mapping through the requested keys produces a1, null, a3 in the expected positions. It also preserves a duplicate a1 position, which matters for generic batch functions even if a particular loader deduplicates cached requests. The local index assumes unique row IDs within the eligible source result. A production adapter should detect conflicting duplicate rows and apply visibility before indexing rather than using an index as a substitute for authorization.
const keys = ["a1", "a2", "a3", "a1"];
const rows = [{id:"a3",name:"Mira"},{id:"a1",name:"Lina"}];
const byId = new Map(rows.map(row => [row.id, row]));
const ordered = keys.map(key => byId.get(key) ?? null);
console.assert(ordered[0].name === "Lina");
console.assert(ordered[1] === null);
console.assert(ordered[2].name === "Mira");
console.assert(ordered[3] === ordered[0]);
All assertions describe local mapping only. They prove neither database batching nor the scheduler behavior of an installed DataLoader.
Invalidate within the right lifecycle
A mutation may change an author after that author has been memoized in the same request context. Clear or prime affected loader entries according to the chosen library and domain result; a successful write does not magically update every cache. Across requests, use an explicit shared-cache policy if one exists rather than stretching request memoization into an unbounded global cache. Batching reduces call count but not necessarily rows, bytes or CPU. Large batches and deeply nested lists still need bounds, deadlines and operation demand controls.
Common errors and fixes
Names are assigned to the wrong authors
- Why it happens
- Batch rows were returned in source order instead of key order.
- How to fix it
- Index eligible rows and map the original keys.
- Prevent it next time
- Test reversed source order and a missing middle record.
A mutation returns stale author data
- Why it happens
- The request loader still memoized the previous value.
- How to fix it
- Clear or prime the relevant entry after confirmed success.
- Prevent it next time
- Test read-write-read within one request lifecycle.
One giant batch still times out
- Why it happens
- Call reduction was mistaken for bounded work.
- How to fix it
- Apply source batch limits and bounded nested pagination.
- Prevent it next time
- Measure rows, bytes and deadlines as well as call count.
Check your understanding
1. A source returns a3 then a1 for keys a1,a2,a3. What must the batch adapter return?
Show worked solution
It must return the a1 value, the documented missing outcome for a2, and the a3 value in that order. Returning only two source rows shifts positions and corrupts association. Index by the declared key and map each original key, preserving length. Include a test where unauthorized and absent records follow the same public disclosure policy.
2. Why is one DataLoader shared by all requests risky even if it is faster?
Show worked solution
Its cached values may depend on the principal, tenant, source revision or policy that created them. A later caller can receive an inappropriate value without rechecking those conditions, and invalidation becomes much harder. Use request scope for ordinary loaders, and design any cross-request cache explicitly with identity, freshness and access requirements rather than inheriting them accidentally.
Key takeaways
- Batch functions preserve requested key order and length.
- Fewer calls do not remove isolation, freshness or demand requirements.