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!

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!

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!