N+1 queries and DataLoader

GraphQL · Advanced · 15 min

Without batching, one innocent-looking query can trigger hundreds of database calls.

Resolvers run per object. Fetch 50 books and select author { name }, and the naive Book.author resolver runs 50 separate lookups: one query for the list plus N for the authors, the classic N+1 problem.

DataLoader (the dataloader package, also ported to most languages) fixes it. You create a loader with a batch function that takes an array of keys and returns a promise of values in the same order. Resolvers call loader.load(key); the loader collects every key requested in the same tick of the event loop and calls the batch function once.

Rules that keep it correct:

  • The batch function must return exactly one result per key, in key order (use null or an Error for misses).
  • Create loaders per request (in the context factory). Their memoization cache would otherwise serve stale data and leak data between users with different permissions.
  • Loaders cache by key within the request, so the same author loaded twice hits the database once.

Batching works because promises resolve as microtasks: DataLoader schedules its dispatch after the current batch of resolver calls. Alternatives include joining in the parent resolver using info look-ahead, or query planners like those in Hasura and PostGraphile.

Example

import DataLoader from "dataloader";
const db = { calls: 0,
  async authorsByIds(ids) { this.calls++; return ids.map((id) => ({ id, name: "Author " + id })); },
};
function createContext() {
  return {
    authorLoader: new DataLoader(async (ids) => {
      const rows = await db.authorsByIds(ids);
      const byId = new Map(rows.map((r) => [r.id, r]));
      return ids.map((id) => byId.get(id) ?? null); // same order as ids
    }),
  };
}
const resolvers = { Book: { author: (book, _a, ctx) => ctx.authorLoader.load(book.authorId) } };

const ctx = createContext();
const books = [{ authorId: "a1" }, { authorId: "a2" }, { authorId: "a1" }];
const authors = await Promise.all(books.map((b) => resolvers.Book.author(b, {}, ctx)));
console.log(authors.map((a) => a.name), "db calls:", db.calls);
// [ 'Author a1', 'Author a2', 'Author a1' ] db calls: 1

Remember

  • N+1: one query for the list plus one per item
  • DataLoader batches keys requested in the same tick
  • Batch functions return results in key order
  • One loader instance per request

Watch out for

  • A global DataLoader shared across requests: stale data and cross-user leaks.

Check yourself

  1. Why must DataLoader instances be created per request?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: graphql.org/learn/performance/