Quick Answer
The N+1 query problem occurs when code executes one database query to get a list, then runs a separate query for each item in that list. AI code generators like Cursor and Bolt routinely put database calls inside loops, turning a 1-query operation into 100+ queries. The fix: use JOINs, batch queries, or ORM eager loading.
What Is the N+1 Query Problem?
The N+1 query problem is one of the most common performance killers in web applications. It happens when your code runs 1 query to fetch a list of N items, then runs N additional queries - one per item - to fetch related data. Instead of 1-2 database calls, you're making 101.
N+1 queries are a common database performance problem in web applications. For AI-generated code, the problem is even more prevalent because AI tools generate the simplest working pattern - which is almost always a loop with a query inside.
In a Next.js or Node.js app hitting a PostgreSQL or Supabase database, every query costs at least one network round trip. On a page with 50 items, an N+1 turns 1-2 round trips into 51. If your server and database sit in different regions and each round trip takes 50ms, those 51 sequential queries add over 2.5 seconds to the response - enough to hurt your UX and your Lighthouse score.
Exactly How AI Code Creates N+1 Queries
Here's a realistic example. You ask Cursor or Bolt: "Show a list of blog posts with author names." The AI generates this:
// ❌ BAD - N+1 Query (1 query + N queries)
export async function getPostsWithAuthors() {
// Query 1: Get all posts
const posts = await db.query('SELECT * FROM posts');
// N Queries: Get author for EACH post
const postsWithAuthors = [];
for (const post of posts.rows) {
const author = await db.query(
'SELECT name, avatar FROM users WHERE id = $1',
[post.author_id]
);
postsWithAuthors.push({ ...post, author: author.rows[0] });
}
return postsWithAuthors;
}
If you have 50 posts, this runs 51 database queries. With 200 posts, it's 201 queries. Each query pays a network round trip plus execution time; say 1-5ms when the database is in the same data center. Run one after another, that adds up to 200ms-1000ms of pure waiting - on every single page load.
This pattern is caught by VibeDoctor's PERF-003 check, which flags awaited database calls inside for, for...of, for...in and while loops and .forEach() callbacks. It does not flag the await Promise.all(items.map(async ...)) form, and it checks .js and .ts files, not .tsx or .jsx components, so look at Server Components by hand.
The Fix: One Query Instead of N+1
The solution is to fetch all related data in a single query using a JOIN, or in two queries using an IN clause:
// ✅ GOOD - Single JOIN Query
export async function getPostsWithAuthors() {
const result = await db.query(`
SELECT posts.*, users.name AS author_name, users.avatar AS author_avatar
FROM posts
JOIN users ON users.id = posts.author_id
ORDER BY posts.created_at DESC
`);
return result.rows;
}
// ✅ GOOD - Two Queries (batch approach)
export async function getPostsWithAuthorsBatched() {
const posts = await db.query('SELECT * FROM posts');
const authorIds = [...new Set(posts.rows.map(p => p.author_id))];
const authors = await db.query(
'SELECT id, name, avatar FROM users WHERE id = ANY($1)',
[authorIds]
);
const authorMap = new Map(authors.rows.map(a => [a.id, a]));
return posts.rows.map(post => ({
...post,
author: authorMap.get(post.author_id)
}));
}
The JOIN approach makes 1 query. The batch approach makes 2 queries. Both are constant - they don't grow with the number of posts.
N+1 in Prisma, Drizzle, and Supabase
ORMs don't automatically prevent N+1 queries. Here's how each tool handles it:
| ORM/Tool | N+1 Prone Pattern | Safe Pattern |
|---|---|---|
| Prisma | Loop with prisma.user.findUnique() |
prisma.post.findMany({ include: { author: true } }) |
| Drizzle | Loop with db.select().from(users).where() |
Use .innerJoin() or batch with inArray() |
| Supabase | Loop with .from('users').select().eq('id', x) |
.from('posts').select('*, users(*)') |
| Sequelize | Loop with User.findByPk() |
Post.findAll({ include: User }) |
Prisma's include loads the relation with one extra batched query instead of one per row (or with a single database JOIN when you enable the relationJoins preview feature, per Prisma's docs), and Supabase's nested select fetches related rows in the same request. But AI tools often don't use these - they generate the loop pattern because it's more explicit and matches the step-by-step nature of AI code generation.
Real-World Performance Impact
To put this in perspective, here is the database wait alone for a page that displays 100 items with related data, using the same 1-5ms per query as above (an estimate, not a benchmark):
| Approach | Queries | Database wait (1-5ms per query) |
|---|---|---|
| N+1 (loop queries) | 101 | 101ms-505ms |
| Batch (IN clause) | 2 | 2ms-10ms |
| Single JOIN | 1 | 1ms-5ms |
That's 50-100x fewer database round trips. For an e-commerce product listing, a blog index, or a dashboard - this is the difference between a fast app and one that feels broken. Google's research shows that 53% of mobile site visits are abandoned if pages take longer than 3 seconds to load.
How to Find N+1 Queries in Your Codebase
- Search for database calls inside loops: Look for
awaitinside.map(),.forEach(),for...of, andwhileloops. If the await hits a database, it's likely N+1. - Check your ORM usage: If you're using Prisma, make sure list endpoints use
includeorselectwith relations. If you're using Drizzle, look for JOINs orinArray(). - Monitor query counts: Add logging to your database client to count queries per request. If a single API call generates 20+ queries, investigate.
- Run automated scanning: VibeDoctor's Vibe Check automatically detects database queries inside loops (PERF-003) and flags the exact file and line number. Free to sign up.
- Load test your endpoints: Use a tool like k6 or Artillery to hit your endpoints with realistic data volumes. N+1 problems become obvious under load.
FAQ
Does Prisma automatically prevent N+1 queries?
No. Prisma prevents N+1 only when you use include or nested select. If AI generates a for loop that awaits prisma.user.findUnique() on each pass, you still have an N+1 problem. Prisma's dataloader batches findUnique() calls made in the same tick (for example, inside Promise.all), but it cannot batch awaits that run one after another.
Is Promise.all() with map() still an N+1?
Yes. Promise.all(items.map(async item => db.query(...))) runs N queries in parallel instead of sequentially, which is faster - but it still makes N separate database calls. A single JOIN or batch IN query is always better.
How do I detect N+1 queries in production?
Use database query logging or APM tools like Datadog, New Relic, or even a simple query counter middleware. If an endpoint shows >10 queries, it likely has an N+1 problem. VibeDoctor's PERF-003 check catches the loop form before deployment.
Does this affect Next.js Server Components?
Yes. Server Components and Server Actions are still Node.js code. If you fetch a list in a Server Component and then loop through items making individual database calls, the N+1 still happens - it just happens on the server instead of the client.
What about GraphQL and N+1?
GraphQL has a well-known N+1 problem with nested resolvers. DataLoader is the standard fix - it batches and deduplicates database calls within a single request. AI tools rarely generate DataLoader setup.