Batching instead of N+1: building a custom DataLoader class step by step
Loading data directly from the database inside GraphQL resolvers quickly produces hundreds of individual queries per request once fields get nested. The DataLoader Pattern collects these loading requests within a single tick, executes them as one batch, and caches the results per request, without forcing resolvers to give up their simple, isolated structure.
Table of Contents
- 1. Understanding the N+1 problem in GraphQL PHP
- 2. What sets the DataLoader Pattern apart from naive batching
- 3. Implementing a DataLoader class in PHP
- 4. Integrating with webonyx/graphql-php resolvers
- 5. Request cache: when the DataLoader must not cache
- 6. Using the DataLoader for many-to-many relations
- 7. Error handling in the batch: per-key failures
- 8. Measuring performance: query counts before and after the DataLoader Pattern
- 9. The DataLoader Pattern compared to alternatives
- 10. Summary
- 11. FAQ
1. Understanding the N+1 problem in GraphQL PHP
GraphQL lets clients fetch arbitrarily nested data in a single request. That exact flexibility is the root cause of the so-called N+1 problem: a list of ten orders produces one query for the list itself, followed by ten more individual queries once each order resolver loads its associated customer separately. Without the DataLoader Pattern, the number of database calls grows linearly with the list size, and a field that looks harmless in the schema turns into a performance killer in production.
In PHP GraphQL implementations such as webonyx/graphql-php, every resolver is called in isolation, once per field and once per object in the result list. That is architecturally clean, since a resolver never needs to know about its siblings in the list, but without a countermeasure it leads to exactly this kind of duplicated database access. The DataLoader Pattern solves the problem without giving up resolver isolation: each resolver stays simple and keeps asking for a single customer, while the batching happens behind the scenes, invisible to the resolver code itself.
2. What sets the DataLoader Pattern apart from naive batching
A naive approach against N+1 would be to collect all IDs upfront and load them with a single WHERE id IN (...) query before the resolvers even start. That works for flat lists, but breaks down for deeply nested or dynamically built query trees, because at execution time it is not yet known which IDs will be needed later. The DataLoader Pattern, originally coming from Facebook's JavaScript ecosystem, solves this through an event loop tick: every call to load($id) registers the ID in a queue and immediately returns a promise, without touching the database.
Only once the current synchronous execution segment has finished, meaning every resolver has made its load() calls, does the DataLoader resolve the collected queue in a single batch. PHP has no real event loop like Node.js, which is why libraries such as webonyx/graphql-php work with SyncPromise and trigger the batch execution explicitly through the PromiseAdapter. The DataLoader Pattern stays conceptually identical, but the technical implementation of the "tick" differs noticeably from the asynchronous JavaScript variant.
3. Implementing a DataLoader class in PHP
The core of any DataLoader implementation is a batch load function that accepts an array of keys and returns an array of values in exactly the same order. This guarantee is essential: the DataLoader itself handles mapping results back to the original requests, so the batch function only has to worry about loading efficiently. The following implementation collects keys in a queue, uses SyncPromise for deferred resolution, and automatically deduplicates keys before calling the batch function.
<?php
declare(strict_types=1);
namespace Mironsoft\GraphQlPerformance\DataLoader;
use GraphQL\Executor\Promise\Adapter\SyncPromise;
use GraphQL\Executor\Promise\Promise;
use GraphQL\Executor\Promise\PromiseAdapter;
/**
* Generic DataLoader implementation for webonyx/graphql-php.
* Collects load() calls into a queue and resolves them as a single batch.
*/
final class DataLoader
{
/** @var array<string, Promise> */
private array $promiseCache = [];
/** @var array<int, string|int> */
private array $queue = [];
/** @var array<string, callable> */
private array $pendingResolvers = [];
/**
* @param PromiseAdapter $promiseAdapter Promise adapter from the GraphQL executor
* @param callable $batchLoadFn function(array $keys): array<mixed>, must preserve key order
*/
public function __construct(
private readonly PromiseAdapter $promiseAdapter,
private readonly mixed $batchLoadFn,
) {
}
/**
* Queues a single key for batched loading and returns a deferred promise.
*
* @param string|int $key Identifier to load, e.g. a customer ID
* @return Promise Promise resolving to the loaded value
*/
public function load(string|int $key): Promise
{
$cacheKey = (string) $key;
if (isset($this->promiseCache[$cacheKey])) {
return $this->promiseCache[$cacheKey];
}
if (!in_array($key, $this->queue, true)) {
$this->queue[] = $key;
}
$promise = $this->promiseAdapter->create(
function (callable $resolve) use ($key): void {
// Deferred resolution — actual value is filled in by dispatchQueue()
$this->pendingResolvers[(string) $key] = $resolve;
}
);
$this->promiseCache[$cacheKey] = $promise;
return $promise;
}
/**
* Executes the batch load function once for all queued keys and
* resolves every pending promise with its matching result.
*
* @return void
*/
public function dispatchQueue(): void
{
if ($this->queue === []) {
return;
}
$keys = $this->queue;
$this->queue = [];
/** @var array<int, mixed> $results */
$results = ($this->batchLoadFn)($keys);
foreach ($keys as $index => $key) {
$resolve = $this->pendingResolvers[(string) $key] ?? null;
if ($resolve !== null) {
$resolve($results[$index] ?? null);
}
}
}
}
In practice this base class is not quite enough without a central "tick trigger" that calls dispatchQueue() at the right moment. In webonyx/graphql-php, the SyncPromiseAdapter handles this by automatically working through all open queues whenever it waits on a promise. The DataLoader Pattern lives and dies with this trigger mechanism, which is why the adapter integration is not optional but the actual core of the implementation.
4. Integrating with webonyx/graphql-php resolvers
For resolvers to use a DataLoader, the instance must be created freshly per request and made available through the GraphQL context. A common mistake is registering the DataLoader as a singleton: results from an earlier request would then be reused in a new request, leading to stale or even incorrect data. Building the context per request is therefore a central part of any production DataLoader integration.
<?php
declare(strict_types=1);
namespace Mironsoft\GraphQlPerformance\Resolver;
use Mironsoft\GraphQlPerformance\DataLoader\DataLoader;
use Mironsoft\GraphQlPerformance\Repository\CustomerRepositoryInterface;
/**
* Field resolver for Order.customer using the DataLoader Pattern.
*/
final class OrderCustomerResolver
{
public function __construct(
private readonly CustomerRepositoryInterface $customerRepository,
) {
}
/**
* Creates a fresh DataLoader instance, scoped to a single request context.
*
* @param mixed $context GraphQL request context, holds the promise adapter
* @return DataLoader Configured customer loader
*/
public function createLoader(mixed $context): DataLoader
{
return new DataLoader(
$context->promiseAdapter,
function (array $customerIds): array {
// Single query for all customer IDs collected in this tick
$customers = $this->customerRepository->getByIds($customerIds);
$indexed = [];
foreach ($customers as $customer) {
$indexed[$customer->getId()] = $customer;
}
// Preserve input order, fill gaps with null for missing customers
return array_map(
static fn (int $id) => $indexed[$id] ?? null,
$customerIds
);
}
);
}
/**
* Resolves Order.customer via the batched, deduplicated loader.
*
* @param object $order Order object exposing a customer_id field
* @param array<string, mixed> $args GraphQL field arguments
* @param mixed $context Request context holding the shared DataLoader
* @return mixed Promise resolving to the customer object
*/
public function resolve(object $order, array $args, mixed $context): mixed
{
// Loader is created once per request and reused across all orders in the list
$loader = $context->loaders['customer'] ??= $this->createLoader($context);
return $loader->load($order->customer_id);
}
}
The ??= assignment in the resolver matters: the loader is only created on the first call per field type and request, every subsequent call for the same field in the same list reuses the same instance and therefore the same queue. This sharing of the loader instance across sibling resolvers is exactly the mechanism that turns ten individual queries into a single batch query. Without a shared instance, every resolver would create its own isolated DataLoader, and the DataLoader Pattern would have no effect.
5. Request cache: when the DataLoader must not cache
The internal promiseCache in the DataLoader handles two jobs at once: deduplicating keys within the queue, and memoizing results for repeated load() calls with the same key within the same request. This behavior is deliberately scoped to the lifetime of a single request. A DataLoader whose cache persists across multiple requests returns stale results as soon as data changes, for example when a customer record is updated between two requests.
A second pitfall: if a record is modified by a mutation within the same request, the corresponding cache entry in the DataLoader must be explicitly invalidated, otherwise a subsequent load() call reads the stale, pre-mutation cached value. A clear($key) method that removes the matching entry from promiseCache therefore belongs in every production-ready DataLoader implementation, especially in schemas that mix queries and mutations in the same execution.
6. Using the DataLoader for many-to-many relations
For 1:1 or n:1 relations such as order to customer, the batch function returns exactly one value per key. For many-to-many relations, such as product to categories, the batch function must return an array of values per key. The DataLoader Pattern stays structurally identical, only the return shape of the batch function changes: instead of [$id => $value], a shape of [$id => [$value1, $value2, ...]] is expected, with every entry still positioned according to the order of the input keys.
<?php
// Batch function for a many-to-many relation: product -> categories
function createCategoriesByProductLoader(PromiseAdapter $adapter, CategoryRepositoryInterface $repo): DataLoader
{
return new DataLoader(
$adapter,
function (array $productIds) use ($repo): array {
// Single query joining product_category for all requested product IDs
$rows = $repo->getCategoriesGroupedByProductIds($productIds);
// Group rows by product ID, default to empty array for products without categories
$grouped = array_fill_keys($productIds, []);
foreach ($rows as $row) {
$grouped[$row->product_id][] = $row->category;
}
return array_values($grouped);
}
);
}
Grouping with array_fill_keys() ensures that products without assigned categories also receive an empty array entry instead of null, which is correct for a list field in the GraphQL schema. This pattern applies to any many-to-many relation, such as tags to articles or permissions to user roles, and is one of the cases where the DataLoader Pattern delivers the biggest performance gains compared to naive eager loading.
7. Error handling in the batch: per-key failures
A batch query that succeeds for nine out of ten IDs and hits a database error on the tenth must not fail the entire batch. The specification of the DataLoader Pattern therefore allows the batch function to return an Error object instead of a value for individual keys. The DataLoader recognizes this case and only fails the affected promise, while every other promise in the same batch resolves normally.
In GraphQL execution, a failed promise translates into a partial error: the corresponding field in the result becomes null, an entry appears in the response's errors array, but sibling fields and other list items remain untouched. This behavior follows GraphQL's error model for nullable fields exactly, and is one of the reasons why resolver chains built with the DataLoader Pattern are more robust than a single large SQL join, where one error fails the entire query.
8. Measuring performance: query counts before and after the DataLoader Pattern
Without measurement, the effect of the DataLoader Pattern remains a claim. A simple but effective approach is a query counter wrapped around the database layer as middleware, logging the number of executed queries per request. Comparing before and after the change makes the effect immediately visible, typically going from a linearly growing query count to a constant, small number of batch queries per field type.
# Compare query counts before and after introducing the DataLoader Pattern
# Requires a query counter middleware that logs to /var/log/graphql/queries.log
# Before: naive per-row loading
curl -s -X POST https://api.example.test/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ orders(first: 10) { id customer { name } } }"}' > /dev/null
grep -c "SELECT" /var/log/graphql/queries.log
# Typical output: 11 (1 list query + 10 individual customer queries)
# After: DataLoader Pattern with batching
grep -c "SELECT" /var/log/graphql/queries.log
# Typical output: 2 (1 list query + 1 batched customer query)
In real deployments, the difference is rarely as small as in the example: nested fields like order, customer, address, and payment method multiply the number of individual queries, while the DataLoader stays constant at a single batch query per field type, regardless of list size. Beyond the query counter, it is worth checking the p95 latency of the GraphQL response in an APM tool, since reducing database round trips directly affects response time under load.
9. The DataLoader Pattern compared to alternatives
There are several strategies against N+1 problems in GraphQL that differ noticeably in effort, flexibility, and maintainability. The following table compares the DataLoader Pattern against common alternatives.
| Strategy | Effort | Covers deep nesting | Best fit |
|---|---|---|---|
| Naive resolver loading | Very low | No | Prototypes, small lists |
| Eager loading with JOIN | Medium | Limited | Fixed, flat schemas |
| DataLoader Pattern | Medium, one time | Yes | Production GraphQL APIs of any size |
| Per-field response cache | Low | No | Complementary to the DataLoader Pattern |
| Manual per-endpoint query batching | High | Partial | Legacy APIs without a resolver architecture |
The table shows why the DataLoader Pattern is usually preferred over the other options in practice: it combines full coverage of arbitrarily deep nesting with a manageable, one-time implementation effort per data type. Response cache and the DataLoader Pattern are not mutually exclusive, they complement each other, since the response cache works across requests while the DataLoader cache stays strictly scoped to a single request.
Mironsoft
GraphQL architecture, performance tuning, and Magento integration
Want GraphQL resolvers that stay fast under load?
We analyze existing GraphQL schemas, find N+1 spots in resolver chains, and implement the DataLoader Pattern where it delivers real performance gains, including measurement and monitoring.
Resolver audit
Query counters and N+1 analysis for existing GraphQL schemas
DataLoader implementation
Batching, request cache, and error handling following best practices
Performance monitoring
p95 latency and query counts tracked continuously in your APM tool
10. Summary
The DataLoader Pattern solves the N+1 problem in GraphQL PHP without giving up the clean, isolated structure of individual resolvers. At its core is a batch load function that collects keys within a single execution tick, deduplicates them, and resolves them in one query, combined with a request-scoped cache that answers repeated requests for the same key without another database hit. With webonyx/graphql-php, the SyncPromiseAdapter handles triggering the batch execution, which lets the pattern behave nearly identically to the JavaScript reference implementation despite the lack of a real event loop.
For many-to-many relations, the batch function returns arrays instead of single values, and for error cases the pattern supports granular per-key errors instead of failing the entire batch. To make the effect visible, measure the number of executed database queries before and after the change, typically dropping from a linearly growing count to a constant number per field type. That makes the DataLoader Pattern the standard solution for performant GraphQL resolvers in PHP, whether the schema is built from scratch or part of an existing platform integration.
DataLoader Pattern in GraphQL PHP — Key Takeaways
Batching
Keys are collected in a queue and resolved in a single query only at the end of the tick.
Request cache
Results are cached only within the same request, never across requests, or stale data becomes a risk.
Many-to-many relations
The batch function returns arrays instead of single values, grouped by key, with empty arrays instead of null.
Measurability
Compare query counts and p95 latency before and after the change to prove the effect.