Blog

N+1 Queries: The Silent Performance Killer in NestJS + TypeORM

What is an N+1 Query?

You load a list of 50 authors from the database. Then for each author, your app fires a separate query to fetch their posts. That is 1 query for the authors + 50 queries for the posts = 51 queries total.

That is the N+1 problem. You wanted to fetch data once, but your ORM silently fires N extra queries behind the scenes.

The N+1 query problem happens when your code fetches a list of records (1 query), then lazily loads a relation for each record (N queries). It gets worse the more records you have.


Why Does It Happen in TypeORM?

TypeORM does not load relations automatically. When you access a relation like author.posts, it fires a separate query. Here is a typical setup that causes N+1:

entities/author.entity.ts
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { Post } from "./post.entity";

@Entity()
export class Author {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @Column()
  name: string;

  @Column({ unique: true })
  email: string;

  @OneToMany(() => Post, (post) => post.author)
  posts: Post[];
}
entities/post.entity.ts
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { Author } from "./author.entity";

@Entity()
export class Post {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @Column()
  title: string;

  @Column("text")
  content: string;

  @Column({ default: 0 })
  views: number;

  @CreateDateColumn()
  createdAt: Date;

  @ManyToOne(() => Author, (author) => author.posts)
  author: Author;
}

The Problem Code

Here is a typical NestJS service that has the N+1 bug:

authors.service.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Author } from "./entities/author.entity";

@Injectable()
export class AuthorsService {
  constructor(
    @InjectRepository(Author)
    private readonly authorRepository: Repository<Author>,
  ) {}

  async findAll(): Promise<Author[]> {
    // This only fetches authors — posts are NOT loaded
    const authors = await this.authorRepository.find();

    // Now imagine the controller or serializer accesses author.posts
    // TypeORM will fire a separate query for EACH author
    return authors;
  }
}

When a controller returns these authors and something accesses author.posts, TypeORM fires one query per author. With 100 authors, that is 101 queries.

The SQL looks like this:

generated-queries.sql
-- Query 1: fetch all authors
SELECT * FROM author;

-- Query 2: fetch posts for author #1
SELECT * FROM post WHERE "authorId" = 'uuid-1';

-- Query 3: fetch posts for author #2
SELECT * FROM post WHERE "authorId" = 'uuid-2';

-- ... repeats for every single author
-- Query 101: fetch posts for author #100
SELECT * FROM post WHERE "authorId" = 'uuid-100';

How to Detect N+1 Queries

Before fixing the problem, you need to see it. Enable TypeORM query logging in your data source config:

data-source.config.ts
import { TypeOrmModuleOptions } from "@nestjs/typeorm";

export const dataSourceOptions: TypeOrmModuleOptions = {
  type: "postgres",
  host: process.env.DB_HOST ?? "localhost",
  port: Number(process.env.DB_PORT) ?? 5432,
  username: process.env.DB_USERNAME ?? "postgres",
  password: process.env.DB_PASSWORD ?? "postgres",
  database: process.env.DB_NAME ?? "app",
  entities: [__dirname + "/**/*.entity{.ts,.js}"],
  logging: ["query"], // Logs every SQL query to the console
};

Now run your endpoint and count the queries in the terminal. If you see the same SELECT repeating with different IDs, you have an N+1.


Fix 1: Eager Loading with find Options

The simplest fix. Tell TypeORM to load the relation in the same request using a JOIN:

authors.service.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Author } from "./entities/author.entity";

@Injectable()
export class AuthorsService {
  constructor(
    @InjectRepository(Author)
    private readonly authorRepository: Repository<Author>,
  ) {}

  async findAll(): Promise<Author[]> {
    // relations: ["posts"] tells TypeORM to JOIN posts in one query
    return this.authorRepository.find({
      relations: ["posts"],
    });
  }

  async findOne(id: string): Promise<Author | null> {
    return this.authorRepository.findOne({
      where: { id },
      relations: ["posts"],
    });
  }
}

TypeORM now generates a single query with a LEFT JOIN:

generated-query.sql
SELECT "author".*, "posts".*
FROM "author"
LEFT JOIN "post" "posts" ON "posts"."authorId" = "author"."id";

One query instead of 101. This works well for simple cases, but it can get heavy when relations are deeply nested.


Fix 2: QueryBuilder for More Control

When you need to select specific columns, add conditions on the joined table, or paginate — use the QueryBuilder:

authors.service.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Author } from "./entities/author.entity";

interface FindAuthorsOptions {
  readonly page?: number;
  readonly limit?: number;
  readonly minPostViews?: number;
}

@Injectable()
export class AuthorsService {
  constructor(
    @InjectRepository(Author)
    private readonly authorRepository: Repository<Author>,
  ) {}

  async findAll(options: FindAuthorsOptions = {}): Promise<Author[]> {
    const { page = 1, limit = 20, minPostViews } = options;

    const qb = this.authorRepository
      .createQueryBuilder("author")
      .leftJoinAndSelect("author.posts", "post")
      .orderBy("author.name", "ASC")
      .skip((page - 1) * limit)
      .take(limit);

    // Conditionally filter posts with a minimum view count
    if (minPostViews !== undefined) {
      qb.andWhere("post.views >= :minViews", { minViews: minPostViews });
    }

    return qb.getMany();
  }
}

This gives you full control over what gets joined and filtered, while still avoiding N+1.


Fix 3: Separate Queries with In (Batch Loading)

Sometimes a JOIN is not the best choice — especially with large datasets where a join creates too many duplicate rows. Instead, fetch the relations in a second query using IN:

authors.service.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { In, Repository } from "typeorm";
import { Author } from "./entities/author.entity";
import { Post } from "./entities/post.entity";

@Injectable()
export class AuthorsService {
  constructor(
    @InjectRepository(Author)
    private readonly authorRepository: Repository<Author>,
    @InjectRepository(Post)
    private readonly postRepository: Repository<Post>,
  ) {}

  async findAll(): Promise<Author[]> {
    // Query 1: fetch all authors
    const authors = await this.authorRepository.find();

    // Query 2: fetch all posts for those authors in ONE query
    const authorIds = authors.map((a) => a.id);
    const posts = await this.postRepository.find({
      where: { author: { id: In(authorIds) } },
    });

    // Map posts back to their authors
    const postsByAuthorId = new Map<string, Post[]>();
    for (const post of posts) {
      const authorId = (post.author as unknown as { id: string }).id;
      const existing = postsByAuthorId.get(authorId) ?? [];
      existing.push(post);
      postsByAuthorId.set(authorId, existing);
    }

    for (const author of authors) {
      author.posts = postsByAuthorId.get(author.id) ?? [];
    }

    return authors;
  }
}

Two queries total — no matter how many authors. This avoids heavy JOINs and is often faster for large datasets.

generated-queries.sql
-- Query 1: fetch all authors
SELECT * FROM "author";

-- Query 2: fetch all posts for those authors
SELECT * FROM "post" WHERE "authorId" IN ('uuid-1', 'uuid-2', ..., 'uuid-100');

Fix 4: DataLoader Pattern for GraphQL and Nested Resolvers

If you use GraphQL with NestJS, the N+1 problem shows up in resolvers. The DataLoader pattern groups all loads within a single request into one query.

First, install the dependency:

npm install dataloader

Then create a loader:

posts.loader.ts
import { Injectable, Scope } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import DataLoader from "dataloader";
import { In, Repository } from "typeorm";
import { Post } from "./entities/post.entity";

@Injectable({ scope: Scope.REQUEST })
export class PostsLoader {
  private readonly loader: DataLoader<string, Post[]>;

  constructor(
    @InjectRepository(Post)
    private readonly postRepository: Repository<Post>,
  ) {
    this.loader = new DataLoader<string, Post[]>(async (authorIds: readonly string[]) => {
      // One query for all requested author IDs
      const posts = await this.postRepository.find({
        where: { author: { id: In([...authorIds]) } },
      });

      // Group posts by author ID
      const postsByAuthorId = new Map<string, Post[]>();
      for (const post of posts) {
        const authorId = (post.author as unknown as { id: string }).id;
        const existing = postsByAuthorId.get(authorId) ?? [];
        existing.push(post);
        postsByAuthorId.set(authorId, existing);
      }

      // Return in the same order as the input IDs
      return authorIds.map((id) => postsByAuthorId.get(id) ?? []);
    });
  }

  async loadByAuthorId(authorId: string): Promise<Post[]> {
    return this.loader.load(authorId);
  }
}

Use it in a resolver:

authors.resolver.ts
import { Parent, Query, ResolveField, Resolver } from "@nestjs/graphql";
import { Author } from "./entities/author.entity";
import { Post } from "./entities/post.entity";
import { AuthorsService } from "./authors.service";
import { PostsLoader } from "./posts.loader";

@Resolver(() => Author)
export class AuthorsResolver {
  constructor(
    private readonly authorsService: AuthorsService,
    private readonly postsLoader: PostsLoader,
  ) {}

  @Query(() => [Author])
  async authors(): Promise<Author[]> {
    return this.authorsService.findAll();
  }

  @ResolveField(() => [Post])
  async posts(@Parent() author: Author): Promise<Post[]> {
    // DataLoader batches all calls within the same request into one query
    return this.postsLoader.loadByAuthorId(author.id);
  }
}

Even if 50 authors are returned and each triggers posts(), the DataLoader collapses all 50 into one batched query.


Fix 5: Entity-Level eager: true

For relations that you always need, you can set eager: true on the entity itself:

entities/author.entity.ts
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { Post } from "./post.entity";

@Entity()
export class Author {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @Column()
  name: string;

  @Column({ unique: true })
  email: string;

  @OneToMany(() => Post, (post) => post.author, { eager: true })
  posts: Post[];
}

Now every .find() and .findOne() call automatically joins posts — no need to specify relations.

Warning: Use eager: true sparingly. It loads the relation on every query, even when you do not need it. This can turn an N+1 problem into an over-fetching problem. Only use it for relations that are truly always needed.


Comparison

| Fix | Queries | Best For | Trade-off | | ---------------------- | -------- | -------------------------------------- | ---------------------------------------------- | | relations option | 1 (JOIN) | Simple cases, shallow relations | Heavy JOINs on large datasets | | QueryBuilder | 1 (JOIN) | Filtering, pagination, partial selects | More verbose code | | Batch with In | 2 | Large datasets, avoiding heavy JOINs | Manual mapping required | | DataLoader | 2 | GraphQL, nested resolvers | Extra dependency, request-scoped | | eager: true | 1 (JOIN) | Relations always needed | Over-fetches when you do not need the relation |


Best Practices

1. Never rely on lazy loading in production. TypeORM lazy relations (Promise<T>) are convenient, but they hide N+1 problems. Always load what you need explicitly.

2. Use DTOs to control what gets returned. Do not return raw entities from your controllers. Map to a DTO so you know exactly what data is needed — and only load that.

authors.dto.ts
export class AuthorResponseDto {
  readonly id: string;
  readonly name: string;
  readonly email: string;
  readonly posts: PostSummaryDto[];

  constructor(author: {
    id: string;
    name: string;
    email: string;
    posts: { id: string; title: string }[];
  }) {
    this.id = author.id;
    this.name = author.name;
    this.email = author.email;
    this.posts = author.posts.map((p) => new PostSummaryDto(p));
  }
}

export class PostSummaryDto {
  readonly id: string;
  readonly title: string;

  constructor(post: { id: string; title: string }) {
    this.id = post.id;
    this.title = post.title;
  }
}

3. Enable query logging in development. Always know how many queries your endpoints fire. If you see the same SELECT repeating with different parameters, you have an N+1.

4. Profile with EXPLAIN ANALYZE. After fixing N+1, make sure your JOINs are efficient. Add indexes on foreign keys:

entities/post.entity.ts
import {
  Column,
  CreateDateColumn,
  Entity,
  Index,
  ManyToOne,
  PrimaryGeneratedColumn,
} from "typeorm";
import { Author } from "./author.entity";

@Entity()
export class Post {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @Column()
  title: string;

  @Column("text")
  content: string;

  @Column({ default: 0 })
  views: number;

  @CreateDateColumn()
  createdAt: Date;

  @Index() // Index on the foreign key for faster JOINs
  @ManyToOne(() => Author, (author) => author.posts)
  author: Author;
}

5. Pick the right fix for the context. A simple relations: ["posts"] is enough for most REST endpoints. Use QueryBuilder when you need filtering or pagination. Use DataLoader for GraphQL. Use batch loading with In for large datasets.


Summary

The N+1 query problem is one of the most common performance issues in ORM-based applications. It is easy to miss in development because everything still works — just slowly.

The fix depends on your use case:

  • Simple REST endpoint → relations option or eager: true
  • Need filtering or pagination → QueryBuilder
  • Large datasets → Batch with In
  • GraphQL → DataLoader
  • Always needed relation → eager: true (with caution)

Your ORM should work for you, not against you. Know what queries it generates, and you will never be surprised in production.