DataLoader and solving the N+1 problem

Why a nested field causes one query per parent, how batching and per-request caching work, and the N+1 cases a loader does not fix.

The shape of the problem

query {
  books(first: 50) {
    nodes {
      id
      title
      author { name }
    }
  }
}
-- 1 query for the list
select * from book order by created_at desc limit 50;

-- then 50 more, because Book.author resolves once per node
select * from author where id = 1;
select * from author where id = 1;
select * from author where id = 7;
-- ... 48 more

The resolver has no way to know it is one of fifty. DataLoader solves it by deferring resolution to the end of the current event-loop tick, collecting the keys requested so far, and issuing a single batched query.

💡
A loader is not a cache for the request alone - it must be created per request. A module-level loader leaks data between users and serves stale objects after a mutation.

Creating and using loaders

import DataLoader from "dataloader";

export function createLoaders(db) {
  return {
    authorById: new DataLoader(async (ids) => {
      const rows = await db.author.findMany({ where: { id: { in: ids } } });
      const byId = new Map(rows.map((r) => [r.id, r]));
      // the returned array must be the same length and order as the keys
      return ids.map((id) => byId.get(id) ?? null);
    }),

    booksByAuthorId: new DataLoader(async (authorIds) => {
      const rows = await db.book.findMany({ where: { authorId: { in: authorIds } } });
      const grouped = new Map(authorIds.map((id) => [id, []]));
      rows.forEach((b) => grouped.get(b.authorId)?.push(b));
      return authorIds.map((id) => grouped.get(id) ?? []);
    }),
  };
}

const resolvers = {
  Book: { author: (book, _a, ctx) => ctx.loaders.authorById.load(book.authorId) },
  Query: { books: (_p, args, ctx) => ctx.db.book.findPage(args) },
};
FeatureBehaviourCaution
BatchingOne call per tick, keys deduplicatedNeeds an array back in key order
CachingSame key in one request returns the same promiseCache is not cleared after a mutation unless you call clear()
loadManyResolves an array of keysIndividual rejections still reject the whole call unless handled
Error handlingOne failing key rejects that key onlyUse load per key, not loadMany, if you need graceful degradation
clear() / clearAll()Purges the cacheRequired after a mutation in the same request
Mutation: {
  async updateAuthor(_p, { id, name }, ctx) {
    const updated = await ctx.db.author.update({ where: { id }, data: { name } });
    ctx.loaders.authorById.clear(id);      // otherwise the stale value is served
    return updated;
  },
}

What a loader does not fix

  • A loader cannot batch a query that needs data from the parent before it can even build the filter - it batches keys, not arbitrary logic.
  • Filtering a large collection in the resolver (loading all rows then filtering in JavaScript) is still an N+1 in disguise. Push the filter into the query.
  • Per-object permission checks that call the database are N+1 as well; load the permission set once per request and reuse it.
  • Two loaders can still produce two queries per level. Merging related fetches into one batched query with a join is sometimes worth it.
  • Monitor query counts per operation, not just per request: a resolver that quietly counts as three queries will be invisible in aggregate latency.
// a plugin that counts database calls per operation
const dbCounter = {
  async requestDidStart({ contextValue }) {
    contextValue.dbCalls = 0;
    return {
      async willSendResponse({ contextValue, response }) {
        if (contextValue.dbCalls > 10) {
          log.warn({ op: contextValue.operationName, dbCalls: contextValue.dbCalls }, "high query count");
        }
      },
    };
  },
};

FAQ

Why does my loader return results in the wrong order?
The batch function must return an array with exactly one entry per key, in the same order as the input. Building it with a map over the fetched rows reorders the results - always map over the input keys instead.
Should the loader hold a cache across requests?
No. Cross-request caching needs a shared store with explicit invalidation, and the risk of serving another user's data is severe. Use the loader for request-scoped batching and a real cache layer with keys that include the user and the query shape.

Pagination and the connection pattern Federation, caching and next steps

Last refreshed 2026-09-18.