Mesrai
Back to blog
// essayTechnical Deep Dive

GraphQL N+1 Inside a Resolver: How Mesrai Spots DataLoader Gaps

Real PR catch: GraphQL field resolver doing DB lookup per parent — N+1. DataLoader fix.

Mesrai TeamAugust 17, 20268 min read

A GraphQL post resolver with an `author` field that fetches the user per post. Classic GraphQL N+1. Mesrai caught it.

The vulnerable diff

tsgraphql/post-resolvers.ts
// graphql/post-resolvers.ts
export const PostResolvers = {
  Post: {
    // BUG: runs once per Post — 100 posts = 100 user queries
    author: async (post: Post) =>
      db.users.findUnique({ where: { id: post.authorId } }),
    comments: async (post: Post) =>
      db.comments.findMany({ where: { postId: post.id } }),   // also N+1
  },
};

What is wrong

GraphQL field resolvers run once per parent. A list query that fetches 100 posts then resolves the `author` field per post fires 100 user lookups — the worst-case shape of N+1. The standard fix is DataLoader: a per-request batch object that collects all `.load(id)` calls within an event-loop tick and issues one batched query (`WHERE id IN (...)`). Originally built at Facebook for exactly this pattern; now standard across GraphQL libraries.

The attack

Symptom: 100-post feed query takes 1.5s, mostly DB roundtrips:

textexploit
# DataDog APM trace:
GET /graphql (1,420 ms total)
  - resolve Post[] (15 ms)
  - resolve Post.author × 100 (1,380 ms — 14 ms each)
  - resolve Post.comments × 100 (... another 100 queries)

# With DataLoader:
GET /graphql (52 ms total)
  - resolve Post[] (15 ms)
  - userLoader.loadMany([100 ids]) → 1 query (22 ms)
  - commentsByPostLoader (15 ms)

Latency drops 25-30× on typical queries.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 1 min ago

[mesrai] [code-review] [Performance] [GraphQL-N+1] [high]

GraphQL resolvers run per parent. `author` runs once per Post →
N queries per N posts. DataLoader to batch:

  import DataLoader from "dataloader";

  // Create per request, attached to context:
  function createLoaders() {
    return {
      userById: new DataLoader<string, User | null>(async (ids) => {
        const users = await db.users.findMany({ where: { id: { in: [...ids] } } });
        const byId = new Map(users.map(u => [u.id, u]));
        return ids.map(id => byId.get(id) ?? null);
      }),
    };
  }

  // In Apollo / yoga context:
  context: ({ req }) => ({ user: req.user, loaders: createLoaders() })

  // Resolver:
  author: (post, _args, { loaders }) => loaders.userById.load(post.authorId),

Each request gets fresh loaders. Within a request all `.load(id)`
calls within a tick batch into one query.

The fix

tsgraphql/resolvers + loaders (fixed)
// graphql/post-resolvers.ts — fixed
export const PostResolvers = {
  Post: {
    author: (post, _args, { loaders }) => loaders.userById.load(post.authorId),
    comments: (post, _args, { loaders }) => loaders.commentsByPostId.load(post.id),
  },
};

// graphql/loaders.ts
export function createLoaders() {
  return {
    userById: new DataLoader<string, User | null>(async (ids) => {
      const users = await db.users.findMany({ where: { id: { in: [...ids] } } });
      const byId = new Map(users.map(u => [u.id, u]));
      return ids.map(id => byId.get(id) ?? null);
    }),
    commentsByPostId: new DataLoader<string, Comment[]>(async (ids) => {
      const all = await db.comments.findMany({ where: { postId: { in: [...ids] } } });
      const byPost = new Map<string, Comment[]>();
      for (const c of all) {
        (byPost.get(c.postId) ?? byPost.set(c.postId, []).get(c.postId)!).push(c);
      }
      return ids.map(id => byPost.get(id) ?? []);
    }),
  };
}

Per-request loaders attached to context. Each resolver hands its id to the loader; the loader batches within one event-loop tick into one query. 100-post query collapses from 101 queries to 3.

Why human review missed it

GraphQL N+1 is the most common performance bug in GraphQL APIs. Apollo and similar frameworks don't enforce DataLoader — it's convention. Mesrai catches every field resolver that issues a DB call without going through a loader.

Related rules + further reading

Mesrai rule pack: performance/graphql-dataloader-required — flags field resolvers calling DB directly.

GraphQL docs: DataLoader pattern.

Originally Facebook's solution to this exact bug class.

Takeaway

DataLoader per request. Resolvers call `.load(id)`. Single query per field. Mesrai catches every direct-DB resolver.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.