Fixing N+1 Queries in GraphQL With DataLoader

By James Nguyen Updated September 24, 2026
Fixing N+1 Queries in GraphQL With DataLoader

A GraphQL query for a list of twenty blog posts, each with its author's name, was firing twenty-one database queries, one to fetch the posts and then one more per post to resolve its author field, and nobody noticed until a slow-query alert fired in production. This is the N+1 problem, and it's close to universal in GraphQL resolvers written the naive way, since each field resolver runs independently with no visibility into its siblings. DataLoader is the standard fix, and understanding why it works, not just installing it, is what actually prevents the next version of this bug.

Why Field-Level Resolvers Create This Problem by Design

GraphQL's execution model resolves each field independently, which is exactly what makes the API flexible, a client can request author on a post without the schema needing a special "posts with authors" endpoint. The cost of that flexibility is that a naive author resolver, doing db.author.findById(post.authorId) with no awareness of the nineteen sibling posts also resolving their own author field in the same request, has no natural way to know it should batch. The resolver isn't wrong, it's just missing the coordination mechanism.

How DataLoader Actually Batches: The Microtask Queue Trick

DataLoader's .load(id) call doesn't hit the database immediately, it returns a Promise and queues the id. Because JavaScript's microtask queue runs all currently-pending synchronous work before yielding to the event loop, every .load() call issued during the same tick of GraphQL resolving all twenty author fields gets queued before DataLoader's batch function actually fires on the next tick. That's the mechanism, not magic, that turns twenty individual database calls into a single call fetching all twenty ids at once.

const authorLoader = new DataLoader(async (authorIds) => {
  const authors = await db.author.findMany({
    where: { id: { in: authorIds } },
  });
  const byId = new Map(authors.map((a) => [a.id, a]));
  // DataLoader requires the returned array to match input order exactly
  return authorIds.map((id) => byId.get(id) ?? null);
});

const resolvers = {
  Post: {
    author: (post, _args, context) => context.authorLoader.load(post.authorId),
  },
};

The Order-Matching Requirement That Bites Almost Everyone Once

DataLoader's contract requires the batch function's return array to be the exact same length as the input array, with results in the exact same order as the requested keys, not just "contains the right authors somewhere." My first implementation returned whatever order the database happened to give back, which usually matched but silently didn't under certain query planner decisions, and mismatched authors started showing up on the wrong posts intermittently. Building an explicit id-to-result Map and mapping the output array from the original input array, as in the snippet above, is the only way to guarantee this reliably rather than hoping the database preserves input order.

A New Loader Instance Per Request, Not a Shared Singleton

DataLoader caches results within its own lifetime by default, which is exactly what you want within a single request, the same author requested by two different posts in the same query should hit the cache, not the database twice. But that cache must not persist across requests, or one user's cached data leaks into another user's response. Creating a fresh DataLoader instance inside the per-request context function, not as a module-level singleton, is the detail that keeps the cache scoped correctly.

const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
  context: async () => ({
    authorLoader: new DataLoader(batchAuthors), // fresh per request
  }),
});

Batching Isn't Free: Watch the IN Clause Size

A batch function fetching a thousand authors in a single WHERE id IN (...) query trades a thousand round trips for one query with a very large parameter list, which is almost always a better trade, but not unconditionally. Extremely large batches can hit database-specific limits on IN clause size or parameter count, and I added a chunking step splitting anything over a few hundred ids into multiple batched queries as a safety valve, rather than assuming batch size would always stay reasonable.

Catching N+1 Regressions Before They Reach Production

The original incident happened because nobody was watching query counts per request. Adding a middleware that counts database queries issued during a single GraphQL request and logs a warning above a threshold, five in our case for a typical list-plus-relations query, catches new N+1 patterns introduced by a future resolver before they need a slow-query alert to surface them. This turned an entire category of performance regression from "found in production" to "found in code review."

DataLoader Solves Batching, Not Caching Across Requests

It's tempting to assume DataLoader's cache means you can skip a separate caching layer entirely. It can't, the cache is scoped to a single request's lifetime by design, and expecting it to reduce database load across multiple requests from different users is a misunderstanding of what it's actually for. For cross-request caching, a real cache layer like Redis sits alongside DataLoader, not instead of it, each solving a different problem.

Final Verdict

DataLoader fixes N+1 queries specifically by exploiting the microtask queue to collect all the load calls issued within one GraphQL resolution pass before firing a single batched query, and every subtlety that trips people up, order matching, per-request scoping, batch size limits, follows directly from understanding that mechanism rather than treating the library as a black box you just wrap resolvers in.

Daniel Justin

About the Author

James Nguyen is a full-stack programmer with more than ten years of experience engineering software systems. Specializing in the Node.js and Python ecosystems, he focuses on backend architecture, API design, and clean data integration. Follow me on YouTube and Instagram.

More Articles