A GraphQL resolver supplies data for a selected field. A query for many parent objects can invoke a child resolver once per parent, producing the N-plus-one pattern. Batching joins keys into fewer backend calls, but a loader cache also has a security boundary: a result fetched for one actor or tenant must not be reused for another. Request-local loaders make that boundary easier to reason about. The batch function must return results in the same order as input keys and represent missing or forbidden objects deliberately. Batching improves call count; it does not replace query admission, indexing, or per-object authorization.
GraphQL Resolver Batching and Tenant Scope
Working case
Reviewer 29 requests 47 cases with owner names. A naive resolver loads the case list once and calls the owner service 47 times. A request-local owner loader collects 47 owner IDs into one bounded backend call, maps returned records back to key order, and filters each case under organization 6. Reviewer 62 opens another request for a different organization and gets a new loader instance. If one owner ID is shared between tenants, the cache key includes the tenant scope or the underlying lookup enforces it, so neither request sees another tenant’s owner record.
Implementation boundary
function orderedOwners(ids, rows) {
const byId = new Map(rows.map(owner => [owner.id, owner]));
return ids.map(id => byId.get(id) ?? null);
}
console.log(orderedOwners([62, 47], [{ id: 47 }, { id: 62 }]).map(owner => owner.id).join(","));
// Output: 62,47Construct loaders per incoming request or authenticated execution context, not as global singletons. Scope cache keys to tenant and, where object policy differs, actor or permission version. Batch only compatible reads and cap the batch size to avoid one giant query. Preserve key order and duplicate-key behavior expected by the loader. Apply authorization on every root and nested path, and avoid leaking forbidden object existence through different error shapes unless the product intends that distinction. Use indexed database queries or a downstream batch endpoint; a loader that loops over keys internally still performs N calls. Trace backend call counts and batch sizes for representative queries.
Cost and boundaries
Without batching, one root lookup plus n nested lookups gives O(n) backend round trips for n cases. A bounded batch can reduce it to roughly O(1) calls per field level while still doing O(n) data work and memory for n results. Cache entries live for a request; a global cache could improve hit rate but creates invalidation and tenant-leak risk. Large batches may stress SQL parameter limits or downstream payload caps, so split them. Measure resolver count, backend calls, batch size, query latency, denied nested objects, and cache hits under account changes.
Failure trace
The service creates one global loader at process start. Reviewer 29 loads owner 62 under organization 6; later reviewer 47 requests owner 62 under another tenant and receives the cached first result. Build the loader inside the request and scope keys to the tenant. Another batch function returns database rows in arbitrary order, attaching the wrong owner to a case. Reorder results by requested key. Test repeated keys, missing owners, forbidden nested records, 47-case pages, concurrent tenants, permission revocation, and a loader backed by an unindexed query.
Verification
- A 47-case selection avoids 47 separate owner calls.
- Batch results match requested keys even if storage returns another order.
- No loader cache crosses actor or tenant requests.
Practice drill
Run a case list with 47 distinct owners and count backend calls before and after batching. Have the batch service return rows out of order and verify the resolver restores key order. Send two requests concurrently under separate tenants, with one overlapping owner ID, and assert no cache sharing. Revoke reviewer 29’s case permission between requests and confirm the next request does not reuse a stale result. Cap one batch at a documented size and inspect latency and memory on larger pages.
Decision note
Batch nested reads per request and preserve tenant and actor scope at every cache and resolver boundary.
Common Mistakes
- Using a process-global loader for private data.
- Batching keys but looping over them inside the batch function.
- Trusting backend row order to match requested key order.
Connected lessons
GraphQL Query and Resolver Boundaries; GraphQL Schema Nullability and Evolution; GraphQL Operation Cost and Admission; GraphQL Mutation Errors and Pagination Contracts; Indexes and Query Plans for Case Feeds; Tenant Scope in Cache and Background Work; Object-Level Authorization for Reads and Writes.
Apply and check
Build Project: tenant-safe GraphQL case API and review Web Development: release and GraphQL decisions quiz.
