CodeRaptor
Back to Performance Issues
High Severity

N+1 Query Problem

The N+1 query problem is one of the most common performance issues in web applications. It occurs when code executes one query to fetch a list of items, then executes N additional queries (one for each item) to fetch related data.

What Is the N+1 Query Problem?

Instead of fetching all needed data in a single query (or a few queries), the application makes:

1 Query

Fetch list of N parent records

SELECT * FROM users LIMIT 10
+ N Queries

One query for each parent record to fetch related data

SELECT * FROM posts WHERE user_id = 1
SELECT * FROM posts WHERE user_id = 2
... (10 more queries)

Result: 11 queries instead of 1 or 2!

With 100 users, this becomes 101 queries. With 1000 users, 1001 queries!

Problem vs Solution

Why N+1 Happens So Often

ORMs make database access easy, but this convenience masks what's actually happening. A seemingly innocent loop accessing related data can trigger hundreds of database queries without you realizing it.

Lazy Loading (Default)

ORM loads related data only when accessed - each access = new query

user.posts → SELECT * FROM posts WHERE user_id = ?
Eager Loading (Optimized)

ORM loads everything upfront with JOINs - fewer queries

include: [Post] → SELECT * FROM users JOIN posts...

Sequelize/TypeORM - Lazy Loading

Problem - N+1 Queries
// Fetching users and their posts - N+1 problem
async function getUsersWithPosts() {
  const users = await User.findAll();  // 1 query

  for (const user of users) {
    const posts = await user.getPosts();  // N queries (one per user)
    user.posts = posts;
  }

  return users;
}

// Results in: 1 + N queries
// With 100 users: 101 queries! 🔴

⚠️ Each loop iteration hits the database

Solution - Eager Loading
// Using eager loading with include
async function getUsersWithPosts() {
  const users = await User.findAll({
    include: [{ model: Post }]  // Eager load posts with JOIN
  });

  return users;
}

// Results in: 1 or 2 queries (depending on ORM strategy)
// Generated SQL: SELECT users.*, posts.* FROM users LEFT JOIN posts...
// 100 users: Still just 1-2 queries! ✅

✓ Single query with JOIN fetches all data at once

GraphQL - DataLoader Pattern

Problem - Without DataLoader
// GraphQL resolver without batching
const resolvers = {
  User: {
    posts: async (user) => {
      // This runs once per user - N+1 problem!
      return await Post.findAll({ where: { userId: user.id } });
    }
  }
};

// Query for 10 users results in 11 database queries
Solution - DataLoader Batching
import DataLoader from 'dataloader';

// Batch function fetches posts for multiple users at once
const batchLoadPosts = async (userIds) => {
  const posts = await Post.findAll({
    where: { userId: { [Op.in]: userIds } }
  });

  // Group by userId for DataLoader
  return userIds.map(id => posts.filter(p => p.userId === id));
};

const postLoader = new DataLoader(batchLoadPosts);

const resolvers = {
  User: {
    posts: (user) => postLoader.load(user.id)  // Batched!
  }
};

// Query for 10 users results in just 2 queries (users + batched posts)

Raw SQL - JOIN vs Multiple Queries

Problem - Loop Queries
// Fetch users
const users = await db.query('SELECT * FROM users');

// Then fetch posts for each user
for (const user of users) {
  const posts = await db.query(
    'SELECT * FROM posts WHERE user_id = ?',
    [user.id]
  );
  user.posts = posts;
}

// N+1 queries executed
Solution - Single JOIN Query
// Fetch users and posts with JOIN
const results = await db.query(`
  SELECT
    users.*,
    posts.id as post_id,
    posts.title,
    posts.content
  FROM users
  LEFT JOIN posts ON users.id = posts.user_id
`);

// Group results in application code
const users = {};
for (const row of results) {
  if (!users[row.id]) {
    users[row.id] = { ...row, posts: [] };
  }
  if (row.post_id) {
    users[row.id].posts.push({
      id: row.post_id,
      title: row.title,
      content: row.content
    });
  }
}

// Single query for all data!

Prisma - Include vs Separate Queries

Problem - Separate Queries in Loop
// Fetching users then posts separately
const users = await prisma.user.findMany();

// N+1 problem - query for each user
for (const user of users) {
  const posts = await prisma.post.findMany({
    where: { authorId: user.id }
  });
  user.posts = posts;
}

// Result: 1 + N queries 🔴
Solution - Include Relation
// Prisma automatically optimizes with include
const users = await prisma.user.findMany({
  include: {
    posts: true,  // Fetch posts with users
    profile: true // Can include multiple relations
  }
});

// Prisma generates optimized query
// Result: 1-2 queries total ✅

// For complex filtering:
const users = await prisma.user.findMany({
  include: {
    posts: {
      where: { published: true },
      orderBy: { createdAt: 'desc' },
      take: 5  // Only latest 5 posts per user
    }
  }
});

TypeORM - Relations Loading

Problem - Default Lazy Loading
// TypeORM entity with lazy relations
@Entity()
class User {
  @PrimaryGeneratedColumn()
  id: number;

  @OneToMany(() => Post, post => post.author)
  posts: Promise<Post[]>;  // Lazy loaded!
}

// Using lazy relations triggers N+1
const users = await userRepository.find();

for (const user of users) {
  const posts = await user.posts;  // New query each time 🔴
  console.log(posts.length);
}
Solution - Relations in Find Options
// Load relations eagerly with find options
const users = await userRepository.find({
  relations: ['posts', 'profile'],  // Load all at once
});

// Or use QueryBuilder for more control
const users = await userRepository
  .createQueryBuilder('user')
  .leftJoinAndSelect('user.posts', 'posts')
  .leftJoinAndSelect('user.profile', 'profile')
  .where('user.active = :active', { active: true })
  .getMany();

// Single optimized query with JOINs ✅

// For nested relations:
const users = await userRepository.find({
  relations: ['posts', 'posts.comments', 'profile']
});

REST API - Multiple Endpoint Calls

Problem - Client-Side N+1
// Frontend fetching users then posts separately
async function loadUserData() {
  // 1 API call
  const users = await fetch('/api/users').then(r => r.json());

  // N API calls - one per user!
  for (const user of users) {
    const posts = await fetch(`/api/users/${user.id}/posts`)
      .then(r => r.json());
    user.posts = posts;
  }

  return users;
}

// 100 users = 101 HTTP requests 🔴
// Each request has network latency (50-200ms)
// Total time: 5-20 seconds!
Solution - Include Query Parameter
// API endpoint with include parameter
async function loadUserData() {
  // Single API call with include
  const users = await fetch('/api/users?include=posts,profile')
    .then(r => r.json());

  return users; // Already has posts and profile
}

// Backend implementation:
app.get('/api/users', async (req, res) => {
  const includes = req.query.include?.split(',') || [];

  const query = {
    include: {}
  };

  if (includes.includes('posts')) {
    query.include.posts = true;
  }
  if (includes.includes('profile')) {
    query.include.profile = true;
  }

  const users = await prisma.user.findMany(query);
  res.json(users);
});

// Alternative: Use GraphQL
// Client specifies exactly what it needs
const query = `
  query {
    users {
      id
      name
      posts { id title }
      profile { bio }
    }
  }
`;

Performance Impact

With N+1 Problem

10 records11 queries
Response time: ~110ms
100 records101 queries
Response time: ~1,010ms
1000 records1001 queries
Response time: ~10,010ms

With Optimization

10 records2 queries
Response time: ~20ms
100 records2 queries
Response time: ~25ms
1000 records2 queries
Response time: ~50ms

Up to 200x Faster!

Eliminating N+1 queries can improve response times by orders of magnitude

Detection & Prevention

Detecting N+1 Queries

1. Enable Query Logging in Development

See exactly what queries your ORM is generating

// Sequelize
const sequelize = new Sequelize({
  logging: console.log
});

// Prisma - set in .env
DATABASE_URL="postgresql://...?query_log=true"

// TypeORM
logging: ['query', 'error']
2. Use Query Counting in Tests

Assert that endpoints don't execute too many queries

let queryCount = 0;

// Hook into query logging
sequelize.options.logging = () => queryCount++;

// Test
it('should not have N+1 queries', async () => {
  queryCount = 0;
  await getUsers();
  expect(queryCount).toBeLessThan(5); // Strict limit
});
3. Use APM Tools

Production monitoring catches N+1 in real-time

• New Relic
• Datadog APM
• Scout APM
• AppSignal
• Sentry Performance
• Elastic APM

GraphQL N+1 Problem

GraphQL is especially prone to N+1 queries because clients can request nested data at any depth. Each resolver can trigger separate database queries.

The Problem
// GraphQL query
query {
  users {           # 1 query: SELECT * FROM users
    id
    name
    posts {         # N queries: SELECT * FROM posts WHERE user_id = ?
      title         # One for EACH user!
    }
  }
}

// Resolvers without batching
const resolvers = {
  Query: {
    users: () => db.user.findAll()  // 1 query
  },
  User: {
    posts: (user) => db.post.findAll({
      where: { userId: user.id }    // N queries!
    })
  }
};
The Solution: DataLoader
import DataLoader from 'dataloader';

// Batch function: loads posts for multiple users at once
const batchGetPosts = async (userIds) => {
  const posts = await db.post.findAll({
    where: { userId: { $in: userIds } }  // Single query!
  });

  // Group posts by userId
  const postsByUserId = {};
  userIds.forEach(id => postsByUserId[id] = []);
  posts.forEach(post => {
    postsByUserId[post.userId].push(post);
  });

  // Return in same order as userIds
  return userIds.map(id => postsByUserId[id]);
};

// Create DataLoader instance per request
const createLoaders = () => ({
  posts: new DataLoader(batchGetPosts)
});

// Use in resolvers
const resolvers = {
  User: {
    posts: (user, args, { loaders }) => {
      return loaders.posts.load(user.id);  // Batched!
    }
  }
};

// Result: 2 queries total (users + batched posts) ✅
Alternative: Query Complexity Analysis

Limit query depth to prevent expensive nested queries

import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  validationRules: [depthLimit(5)]  // Max 5 levels deep
});

Prevention Best Practices

Use ORM Eager Loading

Always use include/eager loading for associations

User.findAll({ include: [Post] })

Enable Query Logging

Log all SQL queries in development to spot N+1 patterns

sequelize.options.logging = console.log

Use DataLoader for GraphQL

Batch and cache database requests automatically

new DataLoader(batchFn)

Batch Loading in REST

Support include/expand parameters in APIs

/api/users?include=posts,profile

Add Database Indices

Ensure foreign keys are indexed for fast JOINs

CREATE INDEX ON posts(user_id)

Write Performance Tests

Assert on query count in integration tests

expect(queryCount).toBeLessThan(5)

Monitor in Production

Use APM tools to catch N+1 in real traffic

datadog.trace("query", {...})

Code Review Checklist

Look for queries inside loops during reviews

for (user of users) await getPosts()

How CodeRaptor Detects N+1 Queries

CodeRaptor analyzes your ORM usage and data access patterns to identify potential N+1 query problems before they impact production performance.

Detects loops with database queries
Identifies missing eager loading
Flags lazy loading in lists
Suggests DataLoader usage
Checks for missing indices
Recommends JOIN optimizations
Try CodeRaptor Free