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
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.
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.
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.
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.
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.
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.
- Queries per request
- 201 → 2
- Endpoint latency
- several seconds → sub-50ms
- Resolvers fixed
- 3 (asset, instrument, category)
- Schema changes
- none
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.