Skip to content
Revoech
··/··
Work / 02

Batching GraphQL resolvers with DataLoader

A portfolio holdings endpoint was making one database query per position. On a realistic dataset that was over 200 sequential round-trips per request. The fix was a DataLoader and a lesson about where N+1 hides.

Date
Q1 2025
Role
Individual contributor
Stack
Node.js · GraphQL · NestJS · TypeORM · PostgreSQL
Scale
Large monorepo · production API
Status
Shipped Q1 2025
01Problem

The portfolio holdings endpoint returned a list of positions. Each position included details about the underlying asset: name, type, a few regulatory identifiers. The response shape was correct. The latency was not.

On a small local dataset it felt fine. In staging, with a realistic account holding two hundred positions, it took several seconds. The query logs explained why: one query to fetch the positions, then one query per position to fetch its asset. Two hundred and one queries, sequential, for a single HTTP request.

This is the N+1 problem. It is almost invisible in local development and almost always present when a GraphQL resolver fetches a related entity by ID.

02Constraints
  1. The schema was client-facing and could not change. The positions field on portfolios, and the asset field on positions, had to stay as they were.

  2. The resolver structure had to stay field-level. Batching at the root level would have required restructuring the resolver tree in a way that touched downstream clients.

  3. DataLoader instances had to be scoped per HTTP request, not per application lifecycle. A singleton loader would cache asset lookups across requests, which is wrong in a system where asset access is permission-gated per tenant.

03Investigation

I turned on TypeORM query logging in staging and ran the endpoint against a large account. The output was immediately readable: the same SELECT against the assets table, once per position, each with a different id in the WHERE clause. Not parallel. Each one waited for the previous to complete.

The source was the asset field resolver:

assetService.findById(position.assetId)

Called once per position, with no batching and no awareness that 199 other callers were about to issue the same query with a different ID. GraphQL resolves fields concurrently within a layer, but each resolver was still making its own round-trip to the database.

Two hundred and one queries for a single request. The N+1 is invisible in unit tests and only shows up under realistic query shapes.

04Decision

DataLoader defers all calls in the same event loop tick, collects the IDs, issues one batched query, and distributes results back to each caller. The resolver went from:

return assetService.findById(position.assetId)

to:

return this.assetLoader.load(position.assetId)

The loader batches all IDs collected in the tick into a single SELECT * FROM assets WHERE id IN (...) and maps results back by ID. The resolver code barely changes. The query count drops from N+1 to 2.

The loader was scoped per request using NestJS's @Scope(Scope.REQUEST). A fresh instance per HTTP request means no cross-request caching. The scoping decision matters more than the batching in a multi-tenant system.

After fixing the asset resolver I audited the rest of the resolver tree and found the same pattern in two others. All three went out in the same PR.

05Outcomemeasured in staging on a 200-position account
Queries per request
201 → 2
Endpoint latency
several seconds → sub-50ms
Resolvers fixed
3 (asset, instrument, category)
Schema changes
none
06ReflectionWhat I'd do differently

DataLoader should be the default for any resolver that fetches a single entity by ID. Writing findById directly in a field resolver is almost always wrong in a real system. A flat test suite with one or two parent objects will never catch the N+1. It only surfaces under a realistic query shape.

The scoping issue is easy to get wrong. A DataLoader that lives for the application lifecycle will serve stale or cross-tenant data silently. That bug would have been hard to trace after the fact. It should be in the onboarding docs for anyone writing resolvers in this codebase.

I would add a query-count assertion to the integration tests for the holdings endpoint. We did not have one, and a future developer could reintroduce the N+1 by swapping the loader for a direct service call without the test suite saying anything about it.