cswork / libraryOpen practice workspace

Articles

GraphQL DataLoader Interview Questions: Batching, Cache Scope, and Authorization

Practice GraphQL DataLoader interview questions with batching, result order, request-scoped caches, authorization, mutation invalidation, and runnable examples.

A GraphQL endpoint now makes one user lookup instead of fifty. The optimization looks successful—until tenant B receives a user object cached while serving tenant A. The loader reduced database calls and broke the access boundary at the same time.

Strong GraphQL DataLoader interview questions combine performance with correctness. You need to explain which loads form a batch, how results align with keys, what the cache remembers, and whose authorization context applies to a cached result.

The broader GraphQL interview guide covers schema design and federation; this article concentrates on one loader implementation and its failure cases.

Evidence boundary: the reference DataLoader documentation and official GraphQL guidance support the library and authorization rules. The tenant example and diagnostic recommendations are original exercises. The local experiment is not a production benchmark or a verified report of an employer's interview.

What Does DataLoader Actually Batch?

Official fact: DataLoader collects compatible loads within its scheduling window and calls a batch function. The reference implementation combines batching with memoization, and each loader instance owns its own cache. It is a general data-fetching utility, not a feature that GraphQL automatically applies to every resolver. DataLoader reference

The N+1 problem appears when a parent fetch returns N objects and each child resolver independently performs another fetch. One GraphQL operation can therefore cause many downstream requests. Official GraphQL.js guidance shows how a loader supplied through request context can consolidate those related fetches. Solving N+1 with DataLoader

Before proposing a loader, measure downstream calls for one operation. Identify repeated IDs, resolver paths, and the backend's bulk-fetch capability. Wrapping fifty individual SQL calls inside one JavaScript batch function does not create one database query.

A good opening answer is: “I will share a loader within the authorized request, enqueue independent keys together, issue a bounded bulk fetch, and map results back to the requested order.” Each part creates a testable requirement: scope, scheduling, backend work, and result order.

Why Can an Await Loop Defeat Batching?

These two expressions request the same logical users but create different opportunities for scheduling:

// Independent loads are enqueued together.
const users = await Promise.all(
  ids.map(id => loader.load(id))
);

// Each result is awaited before the next load exists.
for (const id of ids) {
  await loader.load(id);
}

Local experiment: we ran DataLoader 2.2.3 on Node.js 24.19.0 with an in-memory repository stub. Concurrent loads for [2,1,2,9] reached the batch function as [2,1,9] with the default cache enabled. The repeated key shared the cached work. In the hero diagram, A and B denote result values for IDs 1 and 2; they are not tenant names. A fresh loader and a sequential loop over [2,1,9] produced three batches.

That result is about this explicit control flow, not a universal promise that one GraphQL operation creates one batch. A later fetch may depend on the result of an earlier one, and different resolver branches may become ready at different times. Draw those dependencies before counting expected calls.

Design recommendation: enqueue independent work before awaiting its combined result. Keep genuinely dependent work sequential. Increasing the scheduler's wait window can collect more work, but it also adds latency. Measure that latency cost before extending the scheduling window.

A database join may also be a better solution when the data is local and the relationship is known. Explain why DataLoader fits the resolver architecture rather than treating it as mandatory GraphQL infrastructure.

How Must Batch Results Match the Keys?

A database can return rows in any order unless its contract specifies otherwise, and it can omit missing rows. The loader must preserve a result slot for each input key. Returning the database array directly can associate the wrong object with a caller.

Official contract: the batch result array must have the same length as its input keys, with each value corresponding to the key at the same index. Missing entries can be represented according to the API's intended null or error semantics. DataLoader batch function

This illustrative factory applies that rule. The repository method must enforce tenant and viewer permissions; its name is a contract, not an implementation of authorization.

function createUsersLoader(principal, repository) {
  return new DataLoader(async ids => {
    const rows = await repository.findVisibleUsers(
      principal, ids
    );
    const byId = new Map(rows.map(row => [row.id, row]));
    return ids.map(id => byId.get(id) ?? null);
  }, { maxBatchSize: 100 });
}

In our fixture, the repository deliberately reversed matching rows and omitted ID 9. Loads [2,1,2,9] still returned [A-two,A-one,A-two,null]. The assertion checks that every caller receives the right result, including the missing key.

Keep key types consistent. If GraphQL IDs arrive as strings while database IDs are numbers, a Map lookup can miss a row that exists. Normalize at a defined boundary, validate the format, and test string IDs versus numeric IDs according to the intended API contract.

With cache:false, repeated keys can appear in the batch input. Our fixture observed [1,1]; the batch function returned two result slots. Do not write an implementation that accidentally works only because the default cache removes duplicates.

Why Is a Global Loader an Authorization Risk?

Official guidance: DataLoader recommends creating separate instances for requests from users with different access permissions. Its cache is request memoization, not a replacement for a shared application cache. Request-scoped caching

Suppose tenant A and tenant B each have a user with ID 1. A loader created for A fetches and caches A-one. Reusing that instance while serving B can return the cached A object without invoking the repository again. On that cache hit, the batch function and its permission check do not run.

Our local fixture reproduced exactly that unsafe reuse. A separate B-scoped instance returned B-one. The demonstration uses a stub with tenant filtering; it proves the instance-lifetime bug, not a complete authorization system.

Create the loader after authenticating the request, capture an immutable principal or authorized scope, and pass that loader through the operation's context. Avoid mutable global variables such as currentTenant, which can change while another request's asynchronous work is still running.

Adding tenant ID to a global cache key is not automatically sufficient. Visibility can also depend on viewer identity, permissions, field projection, policy version, or locale. A shared cache needs an explicit authorization and invalidation design. Request scope reduces this problem but does not eliminate authorization checks on the underlying data access.

Where Should Authorization Live?

Official GraphQL guidance: centralize authorization in the business logic layer so different entry points follow the same rules. Authentication establishes identity; authorization decides which data that identity may access. GraphQL authorization

For our example, findVisibleUsers(principal, ids) should return only objects the caller may read, or an explicitly authorized projection. The resolver should not receive a privileged full object and assume every future field resolver will remember to redact it correctly.

Decide whether a missing and an unauthorized object should both appear as null. That can limit existence disclosure, but the behavior must fit the API contract and internal observability. Do not leak sensitive identifiers in client-facing error messages merely to make debugging easier.

A loader can also serve data under more than one relationship: a public profile and an administrator view of the same user are different results. Separate loaders or appropriately scoped keys can make that distinction explicit. Caching solely by user ID is safe only when the instance's fixed context completely determines the result.

Engineering recommendation: write an access test where two principals request the same ID and must receive different results. A happy-path batching test cannot catch that class of bug. Run the test with both request orders so the result does not accidentally depend on which principal populated the cache first.

What Happens After a Mutation or an Error?

Request-local caching can still become stale within one operation. A mutation updates a user after the old value has already been loaded. Later resolver work can see the cached object unless the application invalidates or replaces it.

In our fixture, replacing the repository's user record left the loader returning A-one. Calling clear(1).prime(1, updatedUser) made the next load return A-updated. Clear the key first because priming leaves an existing cache entry unchanged. If you do not have a trusted updated object, clear and let the next read fetch it.

Only prime with data that has passed the same visibility and projection contract as an ordinary load. An administrator mutation result must not become a shortcut that inserts privileged fields into a less privileged loader. Also invalidate related alternate-key caches when they can return the stale entity.

Official behavior: a batch-level rejection is not cached as individual successful loads, while an Error returned for a specific key is cached. The local fixture confirmed that a repeated per-key failure reused its error until cleared; a rejected batch was attempted again on a later load. DataLoader caching errors

Distinguish stable absence from transient dependency failure. Clearing a transient per-key error may be appropriate, but retries still need limits. Also distinguish loadMany, which can return Error entries, from Promise.all over individual loads, which rejects when an individual promise rejects. Choose error handling deliberately rather than silently treating errors as data.

How Do You Bound Work and Prove the Improvement?

Design recommendation: set a batch-size limit that respects backend parameter and payload constraints. The illustrative limit of 100 is a placeholder, not a recommendation for every database. Splitting batches does not itself limit how many batches execute concurrently; use a separate concurrency control when needed.

Measure database call count, keys per batch, duplicate suppression, backend latency, result count, and operation latency. Compare equivalent requests before and after the change. A lower query count with a slower enormous query is not automatically a performance win.

GraphQL's official performance guidance also calls for pagination, query limits, and monitoring. Batching does not bound the total amount of requested data or prevent an expensive operation from consuming excessive resources. GraphQL performance

Long-lived subscriptions need particular care. A loader that lives for the entire socket can retain stale values and accumulate keys. Define its lifetime around an execution or event where appropriate, and specify how authorization changes and cache invalidation take effect. “Per request” is not precise enough until you identify the actual lifetime in that server.

For a final regression set, include reordered rows, a missing key, repeated keys, independent versus dependent loads, two authorization scopes, a mutation, and a transient error. Those cases test the contract that survives optimization.

Practice the Batch and Access Boundaries

Practice questionWhat to rehearse
Design async batched key-value fetcherSchedule independent loads and align results.
Design a Bookstore Pricing API with Batch FetchesBatch fetches while preserving API semantics.
Reduce frontend network requests efficientlyMeasure downstream work as well as network count.
Scale an Authorization CheckerKeep permission scope in the data-access contract.
Implement Role-Aware User Information ViewsAvoid serving privileged projections from shared caches.

Rehearse by tracing [2,1,2,9] through the batch and back to callers. Then reuse the loader across tenants and identify the exact cache hit that bypasses the repository. Finish by updating user 1 and explaining which cache entries must change.

Continue with Backend Engineer interview practice. A defensible answer shows both the calls you removed and the access, ordering, and freshness properties you preserved.

Sources and Further Reading