Skip to content
JM.
All posts

graphql · nodejs · performance

GraphQL N+1, actually solved

2026.07.30 · 5 min read · Jeet Mendpara

Every GraphQL API hits N+1 eventually, usually the first time someone writes a query that nests a list inside a list. The fix everyone recommends is DataLoader, and it works — but I've seen it wired up in ways that silently do nothing, and in one way that leaks data between users. This post covers the problem, the correct setup, and the edge cases.

The problem in one query

query {
  posts(first: 20) {
    title
    author { name }
  }
}

The obvious resolver for author:

const resolvers = {
  Post: {
    author: (post) => User.findByPk(post.authorId),
  },
};

GraphQL resolves each post's author field separately. Twenty posts means one query for the posts, then twenty queries for authors — N+1. Nest one level deeper, say each author's latest comments, and it multiplies. Nothing looks wrong in the code; it only shows up in the database logs or under load.

What DataLoader does

DataLoader collects every .load(key) call made during one tick of the event loop, then calls your batch function once with all the keys:

import DataLoader from "dataloader";
import { Op } from "sequelize";
 
const userLoader = new DataLoader(async (ids: readonly number[]) => {
  const users = await User.findAll({ where: { id: { [Op.in]: [...ids] } } });
  const byId = new Map(users.map((u) => [u.id, u]));
  return ids.map((id) => byId.get(id) ?? null);
});

The resolver becomes userLoader.load(post.authorId), and those twenty lookups turn into a single WHERE id IN (...). It also de-duplicates: if twelve of the posts share an author, that author is requested once.

The contract you must keep

That last line in the batch function isn't style. DataLoader requires the batch function to return an array the same length as the keys, in the same order as the keys. The database doesn't care about your order — IN (...) returns rows in whatever order is convenient, and skips ids that don't exist.

Break the contract and you get one of two failures: an error about the array length, or — worse — no error at all, and a post quietly shown with the wrong author. Always build a map and then walk the ids array. Return null (or an Error) for keys with no row, so the positions stay aligned.

Mistake 1: one loader for the whole server

It is tempting to create loaders once, at module level, and import them everywhere. Don't.

DataLoader caches every key it has loaded, for the lifetime of the loader. A module-level loader never goes away, so:

  • Data goes stale. A user renamed an hour ago is still returned with the old name.
  • Data leaks across users. If what's loaded depends on who's asking — permissions, tenant, private fields — one user's result is now cached and served to the next.

Loaders must be created per request, in the GraphQL context:

// Apollo Server
const server = new ApolloServer({ typeDefs, resolvers });
 
app.use(
  "/graphql",
  expressMiddleware(server, {
    context: async ({ req }) => {
      const viewer = await authenticate(req);
      return { viewer, loaders: createLoaders(viewer) };
    },
  }),
);
 
function createLoaders(viewer: Viewer) {
  return {
    user: new DataLoader((ids: readonly number[]) => batchUsers(ids, viewer)),
    postsByAuthor: new DataLoader((ids: readonly number[]) =>
      batchPostsByAuthor(ids, viewer),
    ),
  };
}

Per-request loaders still batch and de-duplicate everything inside one query — which is the whole point — and get thrown away when the response is sent.

Mistake 2: loading children with .load per row

One-to-many relationships need a loader keyed by the parent, returning an array per key:

async function batchPostsByAuthor(authorIds: readonly number[]) {
  const posts = await Post.findAll({
    where: { authorId: { [Op.in]: [...authorIds] } },
  });
  const grouped = new Map<number, Post[]>();
  for (const post of posts) {
    const list = grouped.get(post.authorId) ?? [];
    list.push(post);
    grouped.set(post.authorId, list);
  }
  // Every key gets an array — empty, never missing.
  return authorIds.map((id) => grouped.get(id) ?? []);
}

Same contract, same map-then-walk shape. Authors with no posts get an empty array, not a gap.

Mistake 3: arguments that change the result

If a field takes arguments — posts(status: PUBLISHED), comments(first: 5) — the key is no longer just an id. Two calls with the same id and different arguments must not share a cache entry. Use a composite key and tell DataLoader how to compare it:

new DataLoader(batchFn, {
  cacheKeyFn: ({ authorId, status }) => `${authorId}:${status}`,
});

The case DataLoader can't solve: per-parent limits

"The latest three comments for each of these twenty posts" doesn't batch with a plain IN (...). A LIMIT 3 applies to the whole result, not per post. People either give up and run twenty queries, or fetch every comment and slice in memory — which is fine until a post has fifty thousand comments.

Do the per-parent limit in the database with a window function, inside the batch function:

SELECT * FROM (
  SELECT c.*,
         row_number() OVER (PARTITION BY post_id ORDER BY created_at DESC) AS rn
  FROM comments c
  WHERE post_id = ANY($1)
) ranked
WHERE rn <= 3;

One query, at most three rows per post, grouped by post_id in the batch function exactly like before.

How to know it's working

Turn on query logging in development and run your deepest query. With DataLoader wired correctly, the number of queries is roughly the depth of the query, not the number of rows it returns. If the count still grows with the result size, one of the loaders above is created in the wrong place, or one resolver bypasses it.

Checklist

  • One loader per entity, created per request in context. Never at module level.
  • Batch functions return results in key order, one per key — map, then walk the keys.
  • One-to-many: key by parent, return an array per key, empty arrays for no children.
  • Arguments that change results belong in the cache key.
  • Per-parent limits: a window function inside the batch function.
  • Verify with query logs, not assumptions.