N+1 Queries in Next.js: The Database Performance Killer from AI Code - VibeDoctor 
← All Articles 🐌 Performance Anti-Patterns Critical

N+1 Queries in Next.js: The Database Performance Killer from AI Code

AI-generated code puts database queries inside loops, creating N+1 problems that cause 10-100x slower page loads. Learn the fix.

PERF-003

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.

According to Veracode's performance analysis, N+1 queries are present in 34% of web applications and are the #1 database performance issue in production. 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, an N+1 query on a page with 50 items can increase page load time from 50ms to 2-5 seconds. With 100 items, your server response time can spike to 10+ seconds - enough to crash your UX and tank 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 = await Promise.all(
    posts.map(async (post) => {
      const author = await db.query(
        'SELECT name, avatar FROM users WHERE id = $1',
        [post.author_id]
      );
      return { ...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 has network latency (typically 1-5ms within the same data center). 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 detects database queries inside loops, .map(), .forEach(), and for...of constructs.

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 getPostsWithAuthors() {
  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 and Supabase's nested select syntax handle JOINs automatically. 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 are typical response times for a page that displays 100 items with related data:

Approach Queries Response Time
N+1 (loop queries) 101 500ms–2000ms
Batch (IN clause) 2 10ms–30ms
Single JOIN 1 5ms–20ms

That's a 50-100x performance improvement. 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 users abandon pages that take longer than 3 seconds to load.

How to Find N+1 Queries in Your Codebase

  1. Search for database calls inside loops: Look for await inside .map(), .forEach(), for...of, and while loops. If the await hits a database, it's likely N+1.
  2. Check your ORM usage: If you're using Prisma, make sure list endpoints use include or select with relations. If you're using Drizzle, look for JOINs or inArray().
  3. Monitor query counts: Add logging to your database client to count queries per request. If a single API call generates 20+ queries, investigate.
  4. Run automated scanning: Tools like VibeDoctor (vibedoctor.io) automatically detect database queries inside loops (PERF-003) and flag the exact file and line number. Free to sign up.
  5. 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 loop that calls prisma.user.findUnique() inside .map(), you still have an N+1 problem. Prisma does batch some internal queries, but it can't optimize explicit loops.

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 them 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.

Scan your codebase for this issue - free

VibeDoctor checks for PERF-003 and 128 other issues across 15 diagnostic areas.

SCAN MY APP →
← Back to all articles View all 129+ checks →