DataLoader: the real fix for N+1
Resolvers are called per object - batching is what makes that survivable.
Open this lesson in the learning hubKey points
- GraphQL calls a field resolver once per parent object. Ask for 100 orders and their customers and the customer resolver runs 100 times.
- That is inherent to the execution model, not a mistake in your schema. Any nested field on a list has this shape.
- DataLoader fixes it by deferring: within one execution tick it collects every requested key, then calls your batch function once with all of them.
- It also caches per request, so the same customer requested by ten different orders is loaded once and the duplicates are served from the cache.
- The cache must be per request, never global. A loader shared across requests serves one user data to another - a serious and easily-made mistake.
- The batch function must return results in the same order as the keys, with a null or placeholder for anything missing. Returning a shorter list silently misaligns every result.
Example
// WITHOUT batching: 1 + N queries.
@SchemaMapping(typeName = "Order")
public Customer customer(Order order) {
return customerRepository.findById(order.customerId()).orElse(null);
}
// query { orders(first: 100) { customer { name } } }
// -> 1 query for orders + 100 for customers
// WITH DataLoader: 2 queries, whatever the list size.
@Configuration
class LoaderConfig {
@Bean
BatchLoaderRegistry.RegistrationSpec<Long, Customer> customerLoader(
BatchLoaderRegistry registry, CustomerRepository repo) {
registry.forTypePair(Long.class, Customer.class)
.registerMappedBatchLoader((keys, env) ->
Mono.fromCallable(() -> repo.findAllById(keys).stream()
// A MAPPED loader is keyed, so a missing id is
// simply absent - no ordering to get wrong.
.collect(Collectors.toMap(Customer::id, c -> c)))
.subscribeOn(Schedulers.boundedElastic()));
return null;
}
}
@SchemaMapping(typeName = "Order")
public CompletableFuture<Customer> customer(Order order,
DataLoader<Long, Customer> loader) {
// Returns immediately. The framework batches every key collected
// during this tick and calls the loader once.
return loader.load(order.customerId());
}
/*
* WHAT HAPPENS:
*
* 100 calls to customer() -> 100 x loader.load(id)
* -> nothing executes yet
* -> at the end of the tick, ONE call:
* findAllById([1, 2, 3, ... 100])
* -> each future completes with its own result
*
* 101 queries becomes 2.
*
* THE ORDERING TRAP with a LIST loader (not the mapped one):
* keys [1, 2, 3]
* returns [c1, c3] <- id 2 was deleted
* -> order 2 now gets c3, and order 3 gets null
* Return the SAME LENGTH with nulls, or use a mapped loader.
*
* AND: the DataLoader cache is PER REQUEST. A shared static loader
* would serve one user cached data to another.
*/
A resolver runs once per parent object, so nested lists are always N+1 - DataLoader batches them into one call per tick.
This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the GraphQL Course course, and every lesson in it is listed on the GraphQL Course contents page.