GraphQL N+1 and DataLoader

What it is

GraphQL resolves a query field by field. A query for 50 orders, each with its customer, executes the orders resolver once and the Order.customer resolver 50 times, because a resolver runs per parent object. If that resolver issues a database query, you have made 51 queries where one join would have done. That is N+1.

DataLoader is the standard fix: a per-request object that collects the keys requested during a tick of the event loop, dispatches them to a batch function in one call, and distributes the results back to the individual promises.

Two things it is commonly confused with. It is not a cache in the ordinary sense, although it memoises within a request; the point is batching. And it is not specific to GraphQL: the same pattern applies anywhere a framework calls you once per item.

The distinguishing feature of GraphQL's version of N+1 is that the client chooses the shape. In REST you know at design time that /orders?expand=customer triggers the join. In GraphQL the client can request any nesting depth, so the query that causes 51 database round trips today may be a query nobody has written yet.

The problem it solves

The naive fix, "just do a join in the top-level resolver", does not work, because the top-level resolver does not know what the client asked for. It might return orders alone or orders with customers, shipments and line-item products. Encoding every combination into the root resolver reinvents REST endpoints and defeats the reason for adopting GraphQL.

DataLoader lets each resolver stay ignorant and local (Order.customer just says "give me customer X") while the batching happens underneath. That separation is the whole design.

Mechanics

The batch function contract

// The two hard rules are in the comments. Getting either wrong produces
// data corruption, not an error.
const customerLoader = new DataLoader(async (customerIds) => {
  const rows = await db.query(
    'SELECT * FROM customers WHERE id = ANY($1)', [customerIds]
  );

  // RULE 1: return an array the SAME LENGTH as `customerIds`, in the SAME
  // ORDER. The database returns rows in whatever order it likes, and omits
  // rows for ids that do not exist. Both break positional matching.
  const byId = new Map(rows.map(r => [r.id, r]));

  // RULE 2: a missing key gets null or an Error at its position, never a
  // silently shorter array. A shorter array shifts every result after the
  // gap onto the wrong parent.
  return customerIds.map(id => byId.get(id) ?? null);
});

Those two rules are where the bugs live. A batch function that returns rows in database order attaches customer 7's data to order 3, and nothing throws.

Where the loader lives

// Per request. NEVER a module-level singleton.
app.use('/graphql', (req, res) =>
  createHandler({
    schema,
    context: () => ({
      user: req.user,
      loaders: {
        customer: new DataLoader(batchCustomers),
        // Scope the loader to the viewer where the data is access-controlled,
        // or the memoisation leaks one user's rows to another.
        ordersByCustomer: new DataLoader(ids => batchOrders(ids, req.user)),
      },
    }),
  })(req, res)
);

// The resolver stays trivial and local. It has no idea batching exists.
const resolvers = {
  Order: {
    customer: (order, _args, ctx) => ctx.loaders.customer.load(order.customerId),
  },
};

A module-level DataLoader is a security bug, not a performance optimisation. Its memoisation cache would persist across requests and across users, so user A's authorised lookup becomes user B's unauthorised hit. This is the single most important operational rule about DataLoader and the thing to say first if asked about caching.

Java and Netflix DGS

@DgsDataLoader(name = "customers")
public class CustomerDataLoader implements MappedBatchLoader<String, Customer> {

    private final CustomerRepository repo;

    // MappedBatchLoader returns a Map, which sidesteps the ordering rule
    // entirely: absent keys simply have no entry. Prefer it over BatchLoader
    // for exactly that reason.
    @Override
    public CompletionStage<Map<String, Customer>> load(Set<String> keys) {
        return CompletableFuture.supplyAsync(() ->
            repo.findAllById(keys).stream()
                .collect(toMap(Customer::id, identity())));
    }
}

@DgsComponent
public class OrderDataResolver {
    @DgsData(parentType = "Order", field = "customer")
    public CompletableFuture<Customer> customer(DgsDataFetchingEnvironment env) {
        DataLoader<String, Customer> loader = env.getDataLoader("customers");
        Order order = env.getSource();
        return loader.load(order.customerId());
    }
}

DGS registers a fresh loader per request automatically, which removes the most common footgun. MappedBatchLoader over BatchLoader is the detail worth knowing: returning a map makes the length-and-order contract impossible to violate.

The alternative fix: selection-set-aware resolvers

DataLoader batches, it does not join. If you know the client asked for the customer, you can fetch it in the root query:

// Look ahead at what the client selected and adjust the root query.
@DgsQuery
public List<Order> orders(DgsDataFetchingEnvironment env) {
    DataFetchingFieldSelectionSet sel = env.getSelectionSet();
    if (sel.contains("customer")) {
        return repo.findAllWithCustomer();   // one query with a join
    }
    return repo.findAll();                    // one query, no join
}

One query instead of two. The cost is that the root resolver now knows about its children, which is the coupling GraphQL was supposed to remove, and it does not compose past a couple of fields. Use it for the two or three hot paths where the extra round trip actually matters; use DataLoader everywhere else.

A worked example

A storefront API. Query: 50 orders, each with its customer, and each customer with their loyalty tier.

Naive. 1 query for orders, 50 for customers, 50 for tiers. 101 queries. At 2 ms each, that is roughly 200 ms of pure database round trips, serialised, before any of your own logic runs.

With DataLoader. GraphQL executes level by level, so all 50 Order.customer resolvers run within the same tick, and their load calls collect into one batch:

Level 1: orders resolver                 -> 1 query   (50 rows)
Level 2: 50 x Order.customer  -> batched -> 1 query   (WHERE id = ANY([...]))
Level 3: 50 x Customer.tier   -> batched -> 1 query
                                            ─────────
                                            3 queries

101 to 3, and the three are sequential rather than the 100 being sequential, so about 6 ms instead of 200. That is the number to quote.

Where it stops working. Add order.lineItems (10 per order) and then lineItem.product. Level 3 now issues 500 load calls, which batch into one query with 500 ids. Better than 500 queries, and a 500-element IN clause has its own problems: index selectivity collapses, the query planner may switch to a sequential scan, and some drivers cap parameter counts. The fix is maxBatchSize, which splits one enormous query into several reasonable ones:

new DataLoader(batchProducts, { maxBatchSize: 100 });

And the deeper fix is that a client can always ask for more nesting. That is why DataLoader alone is not a complete answer, and why query complexity limiting belongs in the same conversation: score the query before executing it and reject anything above a threshold.

Production evidence

DataLoader originated at Facebook, written by Lee Byron, as the generic version of the batching-and-caching layer their internal GraphQL infrastructure already had. The reference implementation (graphql/dataloader) is the canonical one and its README documents the length-and-order contract explicitly.

Netflix DGS provides @DgsDataLoader with per-request registration and both BatchLoader and MappedBatchLoader interfaces, plus DgsDataLoaderRegistry for the wiring. DGS is Netflix's production GraphQL framework and sits behind their federated graph.

Apollo Federation hits the same problem at the gateway layer: resolving an entity across subgraphs would be one _entities call per key, so the reference implementation batches keys into a single call per subgraph per level. Same pattern, different tier, which is good evidence that it is inherent rather than a library quirk.

Shopify and GitHub both run large public GraphQL APIs with published complexity-based rate limits (a query is scored before execution and charged against a budget), which is the industry's admission that batching alone does not bound the cost of a client-shaped query.

The debate

The credible alternative is not to use GraphQL for the case in question. For internal service-to-service traffic, gRPC gives you a schema, deadlines, cancellation and a fixed query shape, and none of this problem exists. For a single-consumer CRUD API, REST endpoints shaped to the consumer are simpler and cacheable by HTTP infrastructure for free.

Where GraphQL earns its cost: many client-driven shapes, mobile clients where round trips and payload size matter, and aggregation across services behind one endpoint. Those are real and the pattern is worth the machinery.

Within GraphQL, the choice is DataLoader everywhere versus selection-set-aware root resolvers on hot paths. My position: DataLoader as the default because it composes and keeps resolvers local, plus look-ahead joins on the two or three paths where the extra round trip is measurably expensive, plus a complexity limit so a client cannot construct a query that batches into something enormous. All three, because each covers a case the others do not.

DataLoader is the wrong answer when the N+1 is not per-key but per-request against an API with no batch endpoint. If the downstream only serves one item per call, batching cannot help; you need a cache, a local replica, or a change to the downstream contract. Saying that unprompted shows you understand the mechanism rather than the incantation.

Follow-up Q&A

"How do you fix N+1 in GraphQL, and why doesn't caching solve it?" DataLoader: per-request, collects keys within an execution tick, dispatches one batch call, distributes results back. Caching does not solve it for three reasons. A response cache does not help a cold or unique query, and client-shaped queries are unique by construction. HTTP caching barely applies because GraphQL is a POST with the query in the body, so there is no cache key without persisted queries. And most importantly the problem is 50 requests for 50 different keys, which is a batching problem; a cache only helps with repeats of the same key.

"Why must a DataLoader be per-request?" Because it memoises. A module-level loader keeps that memo across requests and across users, so an authorised lookup by one user becomes an unauthorised cache hit for another. It also serves stale data indefinitely, since nothing invalidates it. Per-request scoping bounds both problems to the request's lifetime.

"Your batch function returns rows from the database. What can go wrong?" Two things, both silent. The database returns rows in its own order, so positional matching attaches the wrong record to the wrong parent. And it omits rows for ids that do not exist, so the array is shorter than the key array and every result after the gap shifts onto the wrong parent. The fix is to build a map from the rows and project it back over the key array, filling missing keys with null. In Java, MappedBatchLoader returns a map and makes the failure impossible.

"A client sends a deeply nested query and takes the service down. DataLoader was working. What happened?" Batching bounds the number of round trips, not the amount of work. Nesting multiplies the batch sizes: 50 orders times 10 line items times 1 product each is 500 keys in one query, and a 500-element IN may cause the planner to abandon the index. The fixes are maxBatchSize to split oversized batches, query depth limiting, and complexity scoring that rejects the query before execution. Persisted queries are the strong version: only queries you have registered can be executed at all.

"How does this change under Apollo Federation?" The gateway resolves entities by calling each subgraph's _entities resolver with a set of keys, so batching moves to the gateway and the subgraph receives a list rather than a single key. You still need DataLoader inside each subgraph for its own N+1, and you now have a second batching layer to reason about. The failure mode people hit is a subgraph whose _entities resolver loops over the keys and issues one query each, which reintroduces N+1 one level up where it is harder to see.

Common misconceptions

The most common is that DataLoader is a cache. It memoises within one request, which is a side effect of the promise map, but its purpose is batching. Treating it as a cache leads directly to the module-level singleton, which is a data-leak bug.

The second is that N+1 is a GraphQL problem. It is a resolver-per-item problem, and ORMs with lazy loading have had it for two decades. What GraphQL adds is that the client decides the shape, so you cannot enumerate the bad cases in advance.

The third is that batching makes the query cost bounded. It bounds round trips. Nesting still multiplies the work, which is why complexity limiting belongs alongside it.

Interview delivery note

Say this: "DataLoader. It's created per request, collects keys during an execution tick, calls one batch function, and hands results back to the individual promises. Caching doesn't solve it because the problem is 50 lookups for 50 different keys, which is batching, and because GraphQL is a POST so HTTP caching doesn't apply without persisted queries. Two implementation details matter: the batch function must return results in key order with nulls for misses, and the loader must never be a module-level singleton, because its memoisation would leak one user's data to another."

The depth signal is the ordering contract and the per-request security point. Almost everyone can name DataLoader. Far fewer volunteer that a shared loader is a cross-user data leak, or that the batch function returning database-ordered rows silently misattributes records.

Further reading

  • graphql/dataloader README, for the batch-function contract and the caching semantics.
  • Netflix DGS documentation on @DgsDataLoader, MappedBatchLoader and DgsDataLoaderRegistry.
  • Apollo Federation specification, the _entities resolver and entity-key batching at the gateway.
  • GitHub's and Shopify's public GraphQL API rate-limit documentation, for complexity scoring as the complement to batching.