The problem
The app runs fine in development, but once it hits production with real data, an API endpoint suddenly takes 3–10 seconds. No errors, no exceptions — just slow. Looking at the database log shows hundreds of tiny identical queries firing in a loop.
That's N+1.
What N+1 query is
N+1 happens when code fetches a list (1 query), then fires one more query per item in that list — totaling 1 + N queries instead of just 1–2.
Classic example with Prisma (TypeScript):
// Fetch 100 posts
const posts = await prisma.post.findMany({ take: 100 });
// For each post, fetch the author → 100 separate queries
for (const post of posts) {
const author = await prisma.user.findUnique({
where: { id: post.authorId },
});
console.log(post.title, author.name);
}
Result: 101 queries for 100 posts. With 500 posts → 501 queries.
With TypeORM:
const posts = await postRepository.find({ take: 100 });
for (const post of posts) {
// TypeORM lazy loading → separate query for each post
const author = await post.author;
console.log(post.title, author.name);
}
With Sequelize:
const posts = await Post.findAll({ limit: 100 });
for (const post of posts) {
const author = await post.getAuthor(); // N queries
}
This pattern appears in every ORM, every language. Common places it hides:
- Serializers / transformers when formatting responses
- Service layer fetching related data
- Templates rendering nested objects
Why it's dangerous
| 10 items | 100 items | 1,000 items | |
|---|---|---|---|
| Queries fired | 11 | 101 | 1,001 |
| Estimated latency | ~50ms | ~500ms | ~5s |
N+1 throws no errors — the app still returns correct results. In development with a small dataset nobody notices. On production with real data it's slow, and gets slower as traffic grows.
Each extra query adds a round-trip to the database (~1–5ms locally, ~10–50ms if the database is on a separate server). 1,000 queries × 10ms = 10 seconds.
How to detect it
1. Log all SQL queries
Prisma — enable query logging:
const prisma = new PrismaClient({
log: ["query", "info", "warn", "error"],
});
Or use an event:
prisma.$on("query", (e) => {
console.log(`[SQL] ${e.query} — ${e.duration}ms`);
});
When you see dozens of SELECT * FROM users WHERE id = ? lines with different id values back-to-back → there's an N+1.
TypeORM:
const dataSource = new DataSource({
logging: ["query", "error"],
// or logging: true to log everything
});
NestJS + TypeORM:
TypeOrmModule.forRoot({
logging: process.env.NODE_ENV === "development",
})
2. Count queries per request
With Express/NestJS, use middleware to count queries within a single request:
let queryCount = 0;
prisma.$use(async (params, next) => {
queryCount++;
const result = await next(params);
return result;
});
app.use((req, res, next) => {
queryCount = 0;
res.on("finish", () => {
if (queryCount > 20) {
console.warn(`[N+1 WARNING] ${req.path} — ${queryCount} queries`);
}
});
next();
});
3. Assert query count in tests
const queries: string[] = [];
prisma.$on("query", (e) => queries.push(e.query));
await getPostsWithAuthors(); // function under test
expect(queries.length).toBeLessThanOrEqual(3); // must not exceed 3 queries
4. APM tools
Datadog, New Relic, and Sentry Performance can group slow traces and show DB call counts per endpoint. A trace with 100+ small identical DB spans is a clear N+1 signal.
How to fix it
1. Eager loading (most common fix)
Instead of lazy-loading each item, load all related data in a single query.
Prisma — use include:
// Before: 1 + N queries
const posts = await prisma.post.findMany({ take: 100 });
for (const post of posts) {
const author = await prisma.user.findUnique({ where: { id: post.authorId } });
}
// After: single query with JOIN
const posts = await prisma.post.findMany({
take: 100,
include: { author: true },
});
// posts[0].author.name — direct access, no extra query
TypeORM — use relations:
// Before
const posts = await postRepository.find({ take: 100 });
// After: JOIN in one query
const posts = await postRepository.find({
take: 100,
relations: ["author"],
});
TypeORM QueryBuilder:
const posts = await postRepository
.createQueryBuilder("post")
.leftJoinAndSelect("post.author", "author")
.take(100)
.getMany();
Sequelize:
// Before
const posts = await Post.findAll({ limit: 100 });
// After
const posts = await Post.findAll({
limit: 100,
include: [{ model: User, as: "author" }],
});
2. Select only the fields you need
Eager loading can pull back more columns than needed. Use select to limit them:
const posts = await prisma.post.findMany({
take: 100,
select: {
id: true,
title: true,
author: {
select: { name: true, avatar: true },
},
},
});
3. Manual batch query
When the ORM doesn't support it or you need more control — query once for everything, then map in memory:
const posts = await prisma.post.findMany({ take: 100 });
// Collect all authorIds, query once
const authorIds = [...new Set(posts.map((p) => p.authorId))];
const authors = await prisma.user.findMany({
where: { id: { in: authorIds } },
});
// Map in memory — O(n) instead of N queries
const authorMap = new Map(authors.map((a) => [a.id, a]));
const result = posts.map((post) => ({
...post,
author: authorMap.get(post.authorId),
}));
2 queries instead of N+1, regardless of how large N gets.
4. DataLoader pattern (for GraphQL or nested resolvers)
N+1 is especially common in GraphQL because each field resolver runs independently. DataLoader fixes this by batching and deduplicating queries within the same event loop tick:
import DataLoader from "dataloader";
const userLoader = new DataLoader(async (ids: readonly number[]) => {
const users = await prisma.user.findMany({
where: { id: { in: [...ids] } },
});
// Must return in the same order as ids
return ids.map((id) => users.find((u) => u.id === id) ?? null);
});
// Resolver for Post.author
const resolvers = {
Post: {
author: (post) => userLoader.load(post.authorId),
// DataLoader batches all .load() calls in the same tick → 1 query
},
};
Even with 100 Post resolvers running in parallel, DataLoader collects all authorId values and fires 1 query with WHERE id IN (...).
When eager loading causes problems
Eager loading fixes N+1 but can cause over-fetching — loading far more data than needed.
// 100 posts + each author + all of author's posts + all their comments...
const posts = await prisma.post.findMany({
include: {
author: {
include: {
posts: {
include: {
comments: true,
},
},
},
},
},
});
Result: 1 query, but an extremely heavy one returning a massive payload — possibly slower than the original N+1.
The rule: only include or join the relations actually needed for that specific request. Combine with select to limit columns.
Summary
| Situation | Solution |
|---|---|
| ORM with eager loading support | include / relations / leftJoin |
| Need more query control | Manual batch + WHERE id IN (...) |
| GraphQL resolvers | DataLoader |
| Serializer making extra queries | Preload data before serializing |
| Not sure if N+1 exists | Enable SQL logging, count queries per request |
N+1 is easy to fix — the hard part is detection. Enable query logging in the dev environment, set a warning threshold when query count per request exceeds a reasonable number. Catching it while writing code is much easier than debugging it on production with real-world data.