Batching Entity Resolution with DataLoader

A federated _entities query hands your subgraph a list of representations and expects one resolved entity per representation — but the naive __resolveReference issues one datasource call per item, reproducing the classic N+1 problem at the federation boundary. This page shows how to collapse those calls into a single batched fetch with DataLoader.

The gateway already batches at the network layer: for a list of N entities, it sends one _entities operation carrying N representations, not N separate requests. The waste happens inside your subgraph. Apollo Server invokes __resolveReference once per representation, and if each invocation hits the database independently, N representations become N queries. A per-request DataLoader sits between __resolveReference and your datasource, coalesces the keys collected during one tick of the event loop, and dispatches a single keyed batch query. Before wiring this, make sure you have read the parent guide, Optimizing Reference Resolvers for Performance.

When to use this pattern

  • Use it whenever __resolveReference touches a datasource — a database, a REST upstream, or another service — because the gateway routinely resolves entities in lists (search results, list fields, paginated connections) and each list multiplies the calls.
  • Use it when traces show repeated identical-shape queries inside the _entities fetch phase, differing only by id.
  • Skip it only for entities resolved purely from the representation itself (no datasource round trip), where there is nothing to batch.

Prerequisites

Why per-request, not per-process

DataLoader does two jobs: batching and caching. Both are tied to a single request. Batching coalesces keys queued within one event-loop tick into one call. Caching memoises resolved keys so the same id requested twice returns the same promise. That cache is exactly why a loader must never be a module-level singleton — a process-wide loader would leak one user’s data into another request and serve indefinitely stale rows. Create a fresh loader inside the context function on every operation, and let it be garbage-collected when the request ends.

N+1 entity resolution versus DataLoader batching Top row: three __resolveReference calls each issue a separate database query. Bottom row: the same three calls queue keys into a per-request DataLoader that issues a single batched query. Without DataLoader — N queries _entities 3 representations __resolveReference x3 query id=1 query id=2 query id=3 With DataLoader — 1 query _entities 3 representations loader.load(id) x3 DataLoader coalesce keys query id IN (1,2,3) 1 batch

Implementation walkthrough

The batch function is the heart of the pattern. It receives the keys DataLoader has collected, fetches them all at once, and must return an array the same length and order as the keys — with null (or an Error) in any slot whose key had no row. DataLoader maps results back to callers positionally, so a fetch that returns rows in arbitrary order must be re-ordered into key order before returning. The example below builds a lookup map and re-projects.

import DataLoader from "dataloader";
import { buildSubgraphSchema } from "@apollo/subgraph";
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import gql from "graphql-tag";

interface Product { id: string; sku: string; name: string; }

// --- datasource: fetch MANY rows in one round trip ---
async function batchGetProductsByIds(ids: readonly string[]): Promise<Product[]> {
  // e.g. SELECT * FROM products WHERE id = ANY($1)
  return db.query<Product>("SELECT * FROM products WHERE id = ANY($1)", [ids as string[]]);
}

// --- the batch function: result array MUST align to the keys array ---
async function productBatchFn(ids: readonly string[]): Promise<(Product | null)[]> {
  const rows = await batchGetProductsByIds(ids);
  const byId = new Map(rows.map((r) => [r.id, r]));
  // Re-project into key order; missing keys become null, never silently dropped.
  return ids.map((id) => byId.get(id) ?? null);
}

// --- per-request context: a fresh loader on every operation ---
export interface Context { loaders: { product: DataLoader<string, Product | null> }; }

function buildContext(): Context {
  return {
    loaders: {
      // New instance per request => request-scoped cache, no cross-request leakage.
      product: new DataLoader<string, Product | null>(productBatchFn),
    },
  };
}

const typeDefs = gql`
  extend schema
    @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])

  type Product @key(fields: "id") {
    id: ID!
    sku: String!
    name: String!
  }
`;

const resolvers = {
  Product: {
    // __resolveReference now just queues a key. DataLoader batches every key
    // collected during this tick into a single productBatchFn call.
    __resolveReference: (ref: { id: string }, ctx: Context) => {
      return ctx.loaders.product.load(ref.id);
    },
  },
};

const server = new ApolloServer({
  schema: buildSubgraphSchema({ typeDefs, resolvers }),
});

await startStandaloneServer(server, {
  // context runs once per request — exactly where loaders should be created.
  context: async () => buildContext(),
  listen: { port: 4001 },
});

The change to __resolveReference is small but decisive: it no longer performs I/O directly. It returns loader.load(ref.id), a promise. Apollo Server calls __resolveReference once per representation synchronously within the same tick, so all keys land in the loader’s queue before any of them resolve. DataLoader then fires productBatchFn once with the full key list. The within-request cache is a free bonus: if the same product id appears in two representations, productBatchFn sees it only once.

The same loader is reusable beyond __resolveReference — any field resolver that needs a product by id (Order.product, Review.product) should call ctx.loaders.product.load(id) too, so cross-field references batch together within the request rather than each spawning its own query.

Caching scope and where it ends

DataLoader’s cache is intentionally short-lived: it lives and dies with the request. That makes it safe and consistent but does nothing for repeated reads across requests. When the same entities are read on every request — reference data, hot products — layer a request-spanning cache underneath the batch function or in front of the router. Keep the two scopes distinct: DataLoader for per-request coalescing, an external store for cross-request reuse. For the cross-request layer and entity response caching at the router, see Caching Strategies for Federated GraphQL.

If you must clear an entry mid-request after a mutation, call loader.clear(id) so a subsequent load(id) re-fetches rather than serving the now-stale cached promise.

Verification steps

Confirm the subgraph still composes and the entity contract is intact:

rover subgraph check my-graph@current \
  --schema ./products/schema.graphql --name products

Then prove the batching actually happens. The clearest signal is at the datasource. Instrument batchGetProductsByIds to log the number of ids per call, then run a federated query that resolves several products at once:

query {
  orders(first: 10) {
    product { sku name }   # 10 product references in one _entities phase
  }
}

Expected behaviour after batching: a single batchGetProductsByIds invocation logging ten ids, instead of ten separate one-id queries. In Apollo Studio traces the _entities fetch should show one datasource span, not a fan-out of identical spans. A simple before/after table makes the win concrete:

Scenario _entities representations Datasource calls
No DataLoader 10 10
Per-request DataLoader 10 1
Per-request DataLoader, 3 duplicate ids 10 1 (7 unique keys)

Common mistakes & gotchas

Returning the batch results in the wrong order or length. DataLoader matches results to keys by index. If productBatchFn returns only the found rows, every caller after the first gap receives the wrong entity. Always re-project into key order and fill misses with null.

Making the loader a module-level singleton. A process-wide loader caches across requests, leaking data between users and serving stale rows forever. Create it inside the per-request context function.

Doing I/O directly in __resolveReference alongside the loader. If the resolver awaits a database call before calling load, the keys no longer queue within a single tick and batching collapses back to N+1. Keep __resolveReference to a single return loader.load(...).

Frequently Asked Questions

Does DataLoader replace the network batching the gateway already does?

No — they operate at different layers. The gateway batches representations into one _entities HTTP request to your subgraph. DataLoader batches the datasource calls your subgraph makes while resolving those representations. You need both; the gateway’s batching is what hands you a list worth batching internally.

Why must the batch function preserve key order and length?

DataLoader resolves each load(key) promise using the result at the same index it queued the key. A shorter or reordered array silently mismatches entities to callers. Returning null for absent keys keeps the alignment exact and lets resolvers surface a proper not-found.

Can one loader serve both __resolveReference and ordinary field resolvers?

Yes, and it should. Routing every “product by id” lookup — entity references and Order.product alike — through the same per-request loader lets them coalesce into one batch and share the within-request cache, which is the main reason to centralise loaders in context.