Bug Fixes & Debugging Deep Dives

Fixing GraphQL N+1 Queries with Request-Scoped DataLoaders

Count resolver database calls, preserve batch result ordering, isolate loader caches by request, and verify authorization and pagination alongside query reduction.

Fixing GraphQL N+1 Queries with Request-Scoped DataLoaders

A GraphQL response can be compact while its resolvers issue hundreds of database calls. Start by counting calls per operation, not only by looking for one slow SQL statement.

For example, one query for 100 products, one owner lookup for each product, and five additional lookups per product would produce 601 calls if nothing is batched or cached. That is a hypothetical workload, not a measured result from this portfolio.

Follow the fan-out

Draw the resolver path before changing it:

text
products page
  ├─ products query
  ├─ owner(product.ownerId), repeated for each product
  └─ related records, repeated for each product

Record how many distinct owner IDs appear. One hundred products might share a small set of owners. Also record when each resolver schedules its load: work separated by awaited steps may form separate batches.

DataLoader batches compatible loads and memoizes results within its instance. It does not promise that an arbitrary GraphQL operation will collapse to exactly three database queries. Its batch function must return one value per requested key, in the same order, including a deliberate result for missing keys. The DataLoader documentation defines that contract.

Make the batch function independently testable

This adapter leaves database access behind a function that must enforce tenant visibility:

js
export function createOwnerBatch(tenantId, fetchVisibleOwners) {
  return async function ownerBatch(ids) {
    const rows = await fetchVisibleOwners(tenantId, [...new Set(ids)]);
    const byId = new Map(rows.map(row => [row.id, row]));
    return ids.map(id => byId.get(id) ?? null);
  };
}

The adapter preserves order even if the database returns rows in a different order. It uses null for an unavailable owner; the GraphQL schema and resolver must intentionally handle that result, especially for a non-null field.

The supplied fetchVisibleOwners implementation must restrict its query to the authenticated tenant and authorized records. Passing a tenant ID into a helper is not proof that its SQL uses it correctly.

A request-context integration can then construct the loader:

js
import DataLoader from "dataloader";

// Call once per authenticated request, using trusted identity context.
export function createLoaders(tenantId, fetchVisibleOwners) {
  return {
    owner: new DataLoader(createOwnerBatch(tenantId, fetchVisibleOwners)),
  };
}

This integration snippet belongs beside the adapter. It is not a complete GraphQL server. Do not place the loader in a module-level singleton shared by unrelated users. Request scope keeps memoized results from becoming an accidental cross-request cache, but resolvers still need authorization.

Verify correctness before celebrating fewer queries

Use a fixture where requested IDs are [3, 1, 2, 1], returned rows are [1, 3], and ID 2 is unavailable. Assert that the result positions remain [3, 1, null, 1]. Then run a request under a different tenant and verify unavailable records are not returned.

At the API level, compare the response before and after the change. Capture database statement counts for the same operation and page size. A slow-query threshold alone can miss an N+1 problem composed of many individually fast statements.

Mutations need attention too. If a request reads an owner, updates it and reads again, decide whether to clear or prime the relevant loader entry. Otherwise the per-request cache can return the earlier value.

Bound the operation as well as the batch

Batching does not make an unlimited list cheap. Enforce pagination limits and consider depth, estimated cost, execution time and request rate together. A complexity score that counts each field as one can understate the cost of nested lists.

Finish the review with two independent findings: the operation returns the correct authorized data, and it performs an acceptable amount of work for the allowed page size. Query reduction without the first finding is not a successful optimization.

Sardar Mukhshaf

Sardar Mukhshaf

Full-Stack & DevOps Engineer

I build scalable web applications, secure backend systems, and production-ready cloud infrastructure. My engineering focus spans interface design, API architecture, deployment pipelines, and observability. Follow for more deep dives into system architecture, performance engineering, and emerging technologies.

More from the practice

Keep the useful ideas close.

Follow new articles with your preferred RSS reader.

Open the RSS feed ↗