DataLoader: the real fix for N+1

GraphQL Course · lesson 11 of 14 · 7 min read

Resolvers are called per object - batching is what makes that survivable.

Open this lesson in the learning hub

Key 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.