TechLead
Lesson 14 of 20
5 min read
GraphQL

The N+1 Problem

Understand and solve the N+1 query problem in GraphQL, the most common performance issue in GraphQL APIs

What Is the N+1 Problem?

The N+1 problem is the most common performance issue in GraphQL APIs. It occurs when fetching a list of items (1 query) and then fetching related data for each item individually (N additional queries). In REST, this problem is often solved by eager loading at the endpoint level. In GraphQL, the client controls the query shape, making the problem harder to avoid.

Example: The Problem in Action

# This innocent-looking query causes N+1 queries
query GetPosts {
  posts(limit: 20) {
    id
    title
    author {     # <-- This triggers 20 individual user queries!
      name
      avatarUrl
    }
  }
}
// Without optimization — N+1 queries
const resolvers = {
  Query: {
    posts: (_, args, ctx) => {
      // Query 1: Get 20 posts
      return ctx.prisma.post.findMany({ take: args.limit });
    },
  },
  Post: {
    author: (parent, _, ctx) => {
      // Queries 2-21: Get author for EACH post individually!
      // This runs 20 times, once per post
      return ctx.prisma.user.findUnique({
        where: { id: parent.authorId },
      });
    },
  },
};

// SQL queries generated:
// 1. SELECT * FROM posts LIMIT 20
// 2. SELECT * FROM users WHERE id = 'user_1'   -- post 1's author
// 3. SELECT * FROM users WHERE id = 'user_2'   -- post 2's author
// 4. SELECT * FROM users WHERE id = 'user_1'   -- post 3's author (duplicate!)
// ... 17 more queries
// Total: 21 queries for one GraphQL query!

Why It's Worse in GraphQL

  • Client-driven queries: You cannot predict which fields clients will request, so you cannot pre-optimize
  • Deep nesting: Queries can nest deeply: posts -> author -> posts -> comments -> author
  • Multiple relationships: A single query might trigger N+1 on multiple fields simultaneously

Solution 1: DataLoader

DataLoader is the standard solution. It batches multiple individual loads into a single database query by collecting all IDs within a single tick of the event loop and fetching them all at once:

import DataLoader from 'dataloader';

// Create loaders per-request (in context factory)
function createLoaders(prisma: PrismaClient) {
  return {
    // Batch user loading: collects all user IDs, fetches in one query
    userLoader: new DataLoader<string, User>(async (userIds) => {
      // One query: SELECT * FROM users WHERE id IN ('user_1', 'user_2', ...)
      const users = await prisma.user.findMany({
        where: { id: { in: [...userIds] } },
      });

      // DataLoader requires results in the same order as the input IDs
      const userMap = new Map(users.map(u => [u.id, u]));
      return userIds.map(id => userMap.get(id)!);
    }),

    // Batch posts-by-author loading
    postsByAuthorLoader: new DataLoader<string, Post[]>(async (authorIds) => {
      const posts = await prisma.post.findMany({
        where: { authorId: { in: [...authorIds] } },
        orderBy: { createdAt: 'desc' },
      });

      // Group posts by authorId
      const postsByAuthor = new Map<string, Post[]>();
      authorIds.forEach(id => postsByAuthor.set(id, []));
      posts.forEach(post => {
        postsByAuthor.get(post.authorId)!.push(post);
      });

      return authorIds.map(id => postsByAuthor.get(id) || []);
    }),

    // Batch comments-by-post loading
    commentsByPostLoader: new DataLoader<string, Comment[]>(async (postIds) => {
      const comments = await prisma.comment.findMany({
        where: { postId: { in: [...postIds] } },
        orderBy: { createdAt: 'asc' },
      });

      const commentsByPost = new Map<string, Comment[]>();
      postIds.forEach(id => commentsByPost.set(id, []));
      comments.forEach(comment => {
        commentsByPost.get(comment.postId)!.push(comment);
      });

      return postIds.map(id => commentsByPost.get(id) || []);
    }),
  };
}

// Resolvers using DataLoader
const resolvers = {
  Post: {
    author: (parent: Post, _: unknown, ctx: Context) => {
      // This is called 20 times, but DataLoader batches into 1 query
      return ctx.loaders.userLoader.load(parent.authorId);
    },
    comments: (parent: Post, _: unknown, ctx: Context) => {
      return ctx.loaders.commentsByPostLoader.load(parent.id);
    },
  },
  Comment: {
    author: (parent: Comment, _: unknown, ctx: Context) => {
      return ctx.loaders.userLoader.load(parent.authorId);
    },
  },
  User: {
    posts: (parent: User, _: unknown, ctx: Context) => {
      return ctx.loaders.postsByAuthorLoader.load(parent.id);
    },
  },
};

// Result:
// 1. SELECT * FROM posts LIMIT 20
// 2. SELECT * FROM users WHERE id IN ('user_1', 'user_2', 'user_3')  -- batched!
// Total: 2 queries instead of 21!

Solution 2: Join Monster / Prisma Includes

// Using Prisma's include for eager loading
// This works well for predictable queries but less so for dynamic GraphQL queries
const resolvers = {
  Query: {
    posts: (_, args, ctx) => {
      return ctx.prisma.post.findMany({
        take: args.limit,
        include: {
          author: true,     // JOIN users
          comments: {
            include: {
              author: true, // JOIN users for comment authors
            },
          },
        },
      });
    },
  },
  // No field resolvers needed — data is already loaded
};

Key Takeaways

  • Always use DataLoader: It is the standard, proven solution for N+1 in GraphQL
  • Create loaders per-request: Loaders cache results within a single request — create fresh ones for each request
  • Return results in order: DataLoader requires batched results in the same order as input keys
  • Monitor queries: Use query logging to detect N+1 problems in development

Continue Learning