N+1 Query Problem — The Silent Performance Killer in Every ORM

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Your API endpoint returns a list of 100 posts with their authors. In development, it's fast. In production with real data, it's painfully slow. You check your database — it's running 101 queries for a single API call.

That's the N+1 query problem — and it's lurking in almost every ORM-based application.

What is N+1?

Request: "Get 100 posts with their authors"

Query 1:  SELECT * FROM posts LIMIT 100;1 query
Query 2:  SELECT * FROM users WHERE id = 1;+1
Query 3:  SELECT * FROM users WHERE id = 2;+1
Query 4:  SELECT * FROM users WHERE id = 3;+1
...
Query 101: SELECT * FROM users WHERE id = 100;+1

Total: 101 queries instead of 1 or 2.

At 100ms per query, that's 10 seconds for a single request. And it gets worse — N+1 compounds: posts with authors AND comments AND reactions = N×M×P queries.

How It Happens in ORMs

// Prisma — the N+1 trap
const posts = await prisma.post.findMany({ take: 100 })

// This looks innocent. But then:
for (const post of posts) {
  const author = await prisma.user.findUnique({
    where: { id: post.authorId }  // 1 query PER post!
  })
  console.log(post.title, author.name)
}
// Result: 1 + 100 = 101 queries
// TypeORM — equally sneaky
const posts = await postRepository.find({ take: 100 })

for (const post of posts) {
  // If 'author' is a lazy relation, this triggers a query
  const author = await post.author  // 1 query per post!
}

Fix 1: Eager Loading / JOIN

Load everything in ONE query:

// ✅ Prisma — include relation in the same query
const posts = await prisma.post.findMany({
  take: 100,
  include: {
    author: true,  // JOIN in a single query
    comments: {
      include: { author: true }  // Nested include
    }
  }
})
// Result: 1 query with a JOIN — not 101

// ✅ TypeORM — eager loading
const posts = await postRepository.find({
  relations: ['author', 'comments', 'comments.author'],
  take: 100,
})

// ✅ Sequelize — include
const posts = await Post.findAll({
  include: [{ model: User, as: 'author' }],
  limit: 100,
})

Fix 2: DataLoader — Batch + Deduplicate

DataLoader is the most powerful fix — it batches all queries within a single tick into one:

npm install dataloader
import DataLoader from 'dataloader'

// The batch function: receives array of IDs, returns array of results
const userLoader = new DataLoader<number, User>(async (userIds) => {
  console.log(`Batch loading ${userIds.length} users`)
  // ONE query for ALL IDs
  const users = await prisma.user.findMany({
    where: { id: { in: [...userIds] } }
  })

  // IMPORTANT: Return in same order as input IDs
  const userMap = new Map(users.map(u => [u.id, u]))
  return userIds.map(id => userMap.get(id) ?? new Error(`User ${id} not found`))
})

// Now, even with a loop, only ONE DB call is made
const posts = await prisma.post.findMany({ take: 100 })

const postsWithAuthors = await Promise.all(
  posts.map(async post => ({
    ...post,
    author: await userLoader.load(post.authorId),  // Batched!
  }))
)

// Result: 2 queries total (1 for posts, 1 batched user query)
// Instead of 101 queries

DataLoader automatically batches all .load() calls that happen in the same event loop tick, and also caches results within a request (so loading the same user twice = 1 query).

Fix 3: Raw SQL with JOIN

Sometimes the cleanest solution is writing the SQL yourself:

const posts = await pool.query(`
  SELECT
    p.id,
    p.title,
    p.content,
    p.created_at,
    u.id AS author_id,
    u.name AS author_name,
    u.avatar_url AS author_avatar
  FROM posts p
  JOIN users u ON p.author_id = u.id
  ORDER BY p.created_at DESC
  LIMIT 100
`)

// Result: 1 perfectly optimized query

Fix 4: SELECT IN Instead of Loop

When you already have IDs, fetch them all at once:

// ❌ N+1 pattern
const orderIds = orders.map(o => o.id)
const details = []
for (const id of orderIds) {
  details.push(await getOrderDetail(id))  // 1 query per ID!
}

// ✅ Single query with IN
const details = await prisma.orderDetail.findMany({
  where: { orderId: { in: orderIds } }
})

// Map results back to orders
const detailsByOrderId = new Map(details.map(d => [d.orderId, d]))
const ordersWithDetails = orders.map(order => ({
  ...order,
  detail: detailsByOrderId.get(order.id),
}))

Fix 5: GraphQL DataLoader Pattern

In GraphQL, N+1 is especially common because resolvers run independently:

// ❌ Classic GraphQL N+1
const resolvers = {
  Post: {
    author: async (post) => {
      return await prisma.user.findUnique({ where: { id: post.authorId } })
      // Called once per post — N queries!
    }
  }
}

// ✅ With DataLoader in context
const resolvers = {
  Post: {
    author: async (post, args, { loaders }) => {
      return loaders.user.load(post.authorId)  // Batched!
    }
  }
}

// Setup loaders in context
function createLoaders() {
  return {
    user: new DataLoader<number, User>(async (ids) => {
      const users = await prisma.user.findMany({
        where: { id: { in: [...ids] } }
      })
      const map = new Map(users.map(u => [u.id, u]))
      return ids.map(id => map.get(id)!)
    }),
  }
}

Detecting N+1 in Development

// Log all queries in development (Prisma)
const prisma = new PrismaClient({
  log: ['query'],
})

// Count queries per request
let queryCount = 0
prisma.$on('query', () => queryCount++)

app.use((req, res, next) => {
  queryCount = 0
  res.on('finish', () => {
    if (queryCount > 10) {
      console.warn(`⚠️  ${req.path} made ${queryCount} DB queries!`)
    }
  })
  next()
})
# Use pg_stat_statements to find expensive query patterns
SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 20;

Prisma's query count spike is a dead giveaway — if a single request triggers 50+ queries, you have N+1.

The SELECT N+1 Cheat Sheet

PatternProblemFix
Loop with ORM find1 query per iteminclude / JOIN
GraphQL field resolvers1 query per parentDataLoader
Manual ID loopN separate queriesWHERE IN (...)
Lazy loaded relationsQuery on property accessEager load

Conclusion

The N+1 query problem is a rite of passage for every backend developer. It's invisible in development (small data), devastating in production (real data). The fix is almost always simple: use include/JOIN for known relations, DataLoader for dynamic batching, and WHERE IN for ID arrays. Add query logging in development and you'll catch it before it ever reaches production.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro