jsmanifest logojsmanifest

5 Common N+1 Query Problems (And How to Fix Them)

5 Common N+1 Query Problems (And How to Fix Them)

Discover the 5 most common N+1 query problems killing your app's performance and learn practical solutions with real code examples. From ORMs to GraphQL resolvers.

While I was looking over some performance monitoring data the other day, I came across a Node.js application that was making over 2,000 database queries to render a single page. The developer had no idea why their app was so slow in production when it worked perfectly fine in development with their 10 test users.

This is the N+1 query problem, and I was once guilty of shipping code that did exactly this. Little did I know that my innocent-looking ORM code was secretly destroying my database.

What Is the N+1 Query Problem (And Why It's Killing Your App)

The N+1 query problem happens when your code executes one query to fetch N records, then executes N additional queries to fetch related data for each record. So if you fetch 100 users, you might end up executing 101 queries total: 1 query for the users, then 100 queries for their profiles.

In development with 10 users, this feels instant. In production with 10,000 users, your database melts.

Let me show you the five most common ways this problem sneaks into your codebase—and more importantly, how to fix them.

Problem #1: Lazy Loading Relationships in ORMs

This is the classic N+1 scenario that catches most developers when they're first learning ORMs like Prisma, TypeORM, or Sequelize.

Here's what the bad code looks like:

// ❌ BAD: This creates N+1 queries
async function getUsersWithPosts() {
  const users = await prisma.user.findMany(); // 1 query
  
  const usersWithPosts = await Promise.all(
    users.map(async (user) => ({
      ...user,
      posts: await prisma.post.findMany({ // N queries (one per user!)
        where: { userId: user.id }
      })
    }))
  );
  
  return usersWithPosts;
}

I cannot stress this enough! This code looks clean and reasonable, but it's a performance disaster. If you have 100 users, you just executed 101 database queries.

Here's the fix using eager loading:

// ✅ GOOD: This creates 1 query with a JOIN
async function getUsersWithPosts() {
  const users = await prisma.user.findMany({
    include: {
      posts: true // Prisma joins this for us in a single query
    }
  });
  
  return users;
}

The difference? One database query instead of 101. In other words, you've just made your endpoint 100x more efficient in terms of database round trips.

Problem #2: Nested Associations and Deep Object Graphs

When I finally decided to dig deeper into my slow endpoints, I discovered that N+1 problems compound when you have nested relationships. This is where things get really dangerous.

// ❌ BAD: Nested N+1 queries
async function getBlogData() {
  const posts = await prisma.post.findMany(); // 1 query
  
  const postsWithData = await Promise.all(
    posts.map(async (post) => ({
      ...post,
      author: await prisma.user.findUnique({ // N queries
        where: { id: post.authorId }
      }),
      comments: await prisma.comment.findMany({ // N queries
        where: { postId: post.id }
      })
    }))
  );
  
  return postsWithData;
}

Now we're executing 1 + (N × 2) queries. With 100 posts, that's 201 database queries. Wonderful!

Database query visualization

The solution is to eagerly load all relationships at once:

// ✅ GOOD: Single query with multiple joins
async function getBlogData() {
  const posts = await prisma.post.findMany({
    include: {
      author: true,
      comments: true
    }
  });
  
  return posts;
}

Luckily we can leverage our ORM's eager loading capabilities to handle all the joins for us in a single round trip to the database.

Problem #3: N+1 Queries Hidden in List Operations

This one is sneaky because it often hides inside utility functions or business logic. You don't immediately recognize it as a database problem.

// ❌ BAD: Hidden N+1 in array operations
async function enrichUsersWithStats(userIds: string[]) {
  const users = await prisma.user.findMany({
    where: { id: { in: userIds } }
  }); // 1 query
  
  // This looks innocent but executes N queries!
  return users.map(async (user) => ({
    ...user,
    postCount: await prisma.post.count({ // N queries
      where: { authorId: user.id }
    }),
    followerCount: await prisma.follow.count({ // N queries
      where: { followingId: user.id }
    })
  }));
}

I realized this pattern was everywhere in my codebase once I started looking for it. The problem is that .map() with async operations doesn't look like a database bottleneck until you measure it.

Here's a better approach using aggregation:

// ✅ GOOD: Batch the counts in fewer queries
async function enrichUsersWithStats(userIds: string[]) {
  const [users, postCounts, followerCounts] = await Promise.all([
    prisma.user.findMany({
      where: { id: { in: userIds } }
    }),
    prisma.post.groupBy({
      by: ['authorId'],
      where: { authorId: { in: userIds } },
      _count: true
    }),
    prisma.follow.groupBy({
      by: ['followingId'],
      where: { followingId: { in: userIds } },
      _count: true
    })
  ]);
  
  // Create lookup maps
  const postCountMap = new Map(
    postCounts.map(p => [p.authorId, p._count])
  );
  const followerCountMap = new Map(
    followerCounts.map(f => [f.followingId, f._count])
  );
  
  // Enrich in memory
  return users.map(user => ({
    ...user,
    postCount: postCountMap.get(user.id) || 0,
    followerCount: followerCountMap.get(user.id) || 0
  }));
}

Now we're down from potentially 201 queries to just 3 queries, no matter how many users we're processing.

Problem #4: GraphQL Resolvers Without DataLoader

GraphQL is particularly vulnerable to N+1 problems because of how resolvers work. Each field resolver can trigger its own database query, and GraphQL's execution model naturally creates this pattern.

// ❌ BAD: Classic GraphQL N+1 problem
const resolvers = {
  Query: {
    posts: async () => {
      return await prisma.post.findMany(); // 1 query
    }
  },
  Post: {
    author: async (post) => {
      // This runs once per post! N queries
      return await prisma.user.findUnique({
        where: { id: post.authorId }
      });
    }
  }
};

When I finally decided to learn GraphQL properly, I discovered DataLoader was specifically built to solve this exact problem. It batches and caches requests within a single execution context.

// ✅ GOOD: Using DataLoader to batch requests
import DataLoader from 'dataloader';
 
const userLoader = new DataLoader(async (userIds: string[]) => {
  const users = await prisma.user.findMany({
    where: { id: { in: userIds } }
  });
  
  // DataLoader expects results in the same order as keys
  const userMap = new Map(users.map(u => [u.id, u]));
  return userIds.map(id => userMap.get(id));
});
 
const resolvers = {
  Query: {
    posts: async () => {
      return await prisma.post.findMany();
    }
  },
  Post: {
    author: async (post) => {
      // DataLoader batches all these calls into one query
      return await userLoader.load(post.authorId);
    }
  }
};

DataLoader collects all the author resolver calls that happen during a single GraphQL query execution and batches them into one database query. Fascinating how much difference a simple library can make!

GraphQL DataLoader diagram

Problem #5: Conditional Loading Based on Business Logic

This one is subtle because the N+1 problem only happens under certain conditions, making it hard to catch during development.

// ❌ BAD: Conditional N+1 based on user permissions
async function getPostsForUser(userId: string) {
  const user = await prisma.user.findUnique({
    where: { id: userId }
  });
  
  const posts = await prisma.post.findMany();
  
  // Only admins can see private posts
  if (user.role === 'admin') {
    // This creates N+1 for admin users only!
    return await Promise.all(
      posts.map(async (post) => ({
        ...post,
        privateData: await prisma.privatePostData.findUnique({
          where: { postId: post.id }
        })
      }))
    );
  }
  
  return posts;
}

I was once guilty of writing code like this because it seemed like a clean way to handle conditional logic. The problem is that it creates different performance characteristics based on user role, which makes the bug hard to reproduce.

The fix is to eagerly load everything you might need, then filter in memory:

// ✅ GOOD: Eager load conditionally needed data
async function getPostsForUser(userId: string) {
  const user = await prisma.user.findUnique({
    where: { id: userId }
  });
  
  const isAdmin = user.role === 'admin';
  
  const posts = await prisma.post.findMany({
    include: {
      // Only include if needed, but do it in the initial query
      privateData: isAdmin
    }
  });
  
  return posts;
}

How to Detect N+1 Queries in Your Application

The most effective way I've found to catch N+1 problems is to enable query logging in your ORM. Most ORMs have a debug mode that logs every SQL query.

For Prisma, you can enable it like this:

const prisma = new PrismaClient({
  log: ['query', 'info', 'warn', 'error'],
});

When you run your endpoint and see the same query pattern repeating 100 times with only the parameter changing, you've found an N+1 problem.

You can also use tools like:

  • New Relic or DataDog for production monitoring
  • Bullet gem for Rails (but similar tools exist for Node.js)
  • Prisma Studio has built-in query analysis
  • Custom middleware that counts queries per request

I cannot stress this enough! You need visibility into your database queries to catch these problems before they reach production.

Choosing the Right Fix: Eager Loading vs Batching vs Caching

Not every N+1 problem has the same solution. Here's how I decide which approach to use:

Use Eager Loading when:

  • You always need the related data
  • The relationship is one-to-one or one-to-many
  • You're using an ORM with good join support

Use Batching (like DataLoader) when:

  • You're in GraphQL or a similar environment where you can't control query execution order
  • You need the data conditionally but want to optimize when you do
  • You're dealing with many-to-many relationships

Use Caching when:

  • The related data rarely changes
  • You're hitting the same data repeatedly across different requests
  • The data is expensive to compute or fetch

In other words, reach for eager loading first, add batching for GraphQL, and layer caching on top when you have proven hotspots.

Remember that premature optimization is real, but N+1 queries aren't optimization—they're fixing a fundamental flaw in how you're accessing data. The difference between 1 query and 100 queries isn't a micro-optimization; it's the difference between an app that scales and one that doesn't.

And that concludes the end of this post! I hope you found this valuable and look out for more in the future!