06 Oct 2026 · 5 min read
Resolvers, batch loaders, and the N+1 trap in GraphQL
Why per-item GraphQL resolvers cause N+1 database storms in Magento 2 and how a batch loader collapses them into one query per field
Part 3 of the API Platform series — one query per list, not one query per item.
The N+1 problem is the default outcome of naive GraphQL resolvers, not an edge case. A product list query returns 20 items; each item's custom field triggers its own resolver call; each resolver call loads one entity from the database. Twenty items become twenty queries for one field, and a listing page with three such fields issues sixty queries before core code runs a single one. On a catalog page under load, that pattern is the difference between a 200ms response and a 3-second one. This post shows how the trap forms in Magento 2 resolvers and how the batch-loader pattern collapses it back to one query per field.
How the trap forms
A typical first-version resolver looks innocent:
public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null)
{
$sku = $value['sku'];
$record = $this->warrantyRepository->getBySku($sku); // one query per item
return ['months' => (int) $record->getMonths()];
}
GraphQL calls resolve() once per parent item. For a products(items: [...]) response with 20 items, this method runs 20 times, and getBySku hits the database 20 times: same table, same connection, 20 round trips differing only in the bind parameter. Add a second per-item field (a loyalty flag, a vendor name) and the count doubles. The query log shows the signature pattern clearly: one broad catalog query followed by a wall of near-identical single-row selects.
It gets worse with nested lists. A category page returning 10 products, each with 5 configurable variants resolved individually, turns 10 parent calls into 50 child calls. Depth multiplies the fan-out, which is why N+1 in GraphQL is a cliff, not a slope: small queries look fine in development with 3 items and fall over in production with 50.
The batch-loader pattern
The fix is to defer per-item loads within a single request and execute them as one bulk load. In Magento 2 GraphQL this is conventionally a request-scoped batch loader: the resolver registers the requested key (SKU, entity ID) and returns a placeholder, and a single bulk repository call fetches all registered keys before the response serializes. Frameworks in other ecosystems call this a DataLoader; the mechanics here are the same even without a shared library.
// Resolver: collect, do not load.
public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null)
{
return $this->batchLoader->defer($value['sku']);
}
// Batch loader (request-scoped via graphql/di.xml shared instance):
public function defer(string $sku): DeferredValue
{
$this->pending[$sku] = true;
return new DeferredValue(function () use ($sku) {
return $this->loaded[$sku] ?? null;
});
}
public function loadPending(): void
{
if (!$this->pending) {
return;
}
$records = $this->warrantyRepository->getListBySkus(array_keys($this->pending)); // ONE query
foreach ($records as $record) {
$this->loaded[$record->getSku()] = ['months' => (int) $record->getMonths()];
}
$this->pending = [];
}
The wiring detail that makes this work: register the batch loader as a shared (singleton) instance in etc/graphql/di.xml so every resolver invocation in the same request sees the same pending set, and trigger loadPending() from the top-level field resolver (or a plugin on the response pipeline) before child values serialize. Each deferred closure reads from the already-populated $loaded map, so no per-item query ever executes.
What to batch, and what not to
Batch the loads that fan out with list size: repository get() by ID or SKU inside item resolvers, stock status lookups per variant, custom-attribute reads from secondary tables, and any external HTTP call made per item (a per-SKU warranty API call is N+1 over the network, with worse tail latency than the database version). These are the loads where one bulk call replaces dozens.
Do not batch what the platform already batches. Core product collection loading, price index joins, and EAV attribute hydration run as set operations upstream of your resolver; re-fetching that data per item through a second path duplicates work instead of removing it. Before adding a batch loader, check whether the value you need is already present in $value (the parent resolved array) or available via extension attributes on the collection. The cheapest query is the one you delete.
Also scope batch loaders per request, never per process. A loader that caches across requests (a static map, a long-lived singleton outside the GraphQL area) serves stale data to the next shopper. Request scope in graphql/di.xml plus explicit clearing gives you the batching win with no cross-request leakage.
Measuring the win
Verify with the database query log, not with wall-clock time alone. Take the storefront's heaviest product-list query, enable the DB profiler on staging, and count queries before and after:
- Before: 1 catalog query + (items x custom fields) single-row selects. A 20-item page with 2 custom fields shows roughly 41 data queries.
- After: 1 catalog query + 1 bulk query per custom field. The same page shows roughly 3 data queries.
Wall-clock improvement follows, typically 2x to 5x on listing responses, but the query count is the stable metric: it does not vary with machine noise and it predicts production behavior under concurrency, where every redundant query competes for pool connections. Keep the profiler output as the before/after evidence in the pull request. Future regressions then show up as query-count drift in the same fixture, which is far easier to catch in review than a 100ms latency wobble.
Batching fixes how many times you ask. Caching, the subject of Part 4, fixes how often you ask at all.
Next in this series:
Continue with magento2-graphql-caching.