The N+1 Problem

A field resolver runs once per parent object, so a nested field turns one query into N; DataLoader folds those loads into one.

Syntaxnew DataLoader<K, V>(async (keys: readonly K[]) => V[], options?)

Ask for ten authors and each author's posts, and a naive implementation runs one query for the authors and then one query per author: 1 + N. This is not a bug in your code. GraphQL resolves fields per object, so the resolver for Author.posts is called once for each author in the result. At a hundred authors that is 101 round trips for one request, and the client never sees why the response took two seconds.

Batching

DataLoader (the dataloader package, maintained by the GraphQL Foundation) sits between the resolver and the database. load(key) returns a promise and records the key. When the current tick of the event loop drains, DataLoader calls your batch function once with every key it collected and resolves each promise from the result. Ten load calls become one WHERE author_id IN (...).

The batch function has a contract. Return an array the same length as the keys array, in the same order as the keys. Put an Error instance in a slot to fail that one key; throwing fails the whole batch. Get the ordering wrong and you serve the wrong author's posts, with nothing in the logs.

Scope

A loader also memoises: calling load(7) twice hits the batch function once. That cache must not outlive the request. In Nest, declare the loader with @Injectable({ scope: Scope.REQUEST }) — scope is how long an instance lives, and REQUEST means a new one per incoming request. Injecting a request-scoped provider makes the resolver request-scoped too. The alternative is building loaders in the module's context factory, which runs once per request by definition.

Example

Example · typescript
// src/posts/posts-by-author.loader.ts
import { Injectable, Scope } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import DataLoader from 'dataloader';
import { PostEntity } from './post.entity.js';

// Scope.REQUEST: Nest builds one of these per incoming request, so the
// loader's memoisation cache dies with the request.
@Injectable({ scope: Scope.REQUEST })
export class PostsByAuthorLoader {
  readonly byAuthorId: DataLoader<number, PostEntity[]>;

  constructor(
    @InjectRepository(PostEntity)
    private readonly posts: Repository<PostEntity>,
  ) {
    this.byAuthorId = new DataLoader<number, PostEntity[]>(
      // Called once per tick with every id collected since the last tick.
      async (authorIds) => {
        const rows = await this.posts.find({
          where: { authorId: In([...authorIds]) },
        });

        // The database returns one flat list, in its own order. Regroup it.
        const grouped = new Map<number, PostEntity[]>();
        for (const row of rows) {
          const bucket = grouped.get(row.authorId);
          if (bucket) {
            bucket.push(row);
          } else {
            grouped.set(row.authorId, [row]);
          }
        }

        // The contract: one entry per key, in the same order as authorIds.
        // An author with no posts must still get a slot, hence the `?? []`.
        return authorIds.map((id) => grouped.get(id) ?? []);
      },
    );
  }
}

// src/authors/author-posts.resolver.ts
import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Author } from './author.model.js';
import { Post } from '../posts/post.model.js';
import { PostsByAuthorLoader } from '../posts/posts-by-author.loader.js';

@Resolver(() => Author)
export class AuthorPostsResolver {
  // Injecting a REQUEST-scoped provider makes this resolver request-scoped too.
  constructor(private readonly loaders: PostsByAuthorLoader) {}

  @ResolveField('posts', () => [Post])
  posts(@Parent() author: Author) {
    // Called once per author, but only one SELECT leaves the process.
    return this.loaders.byAuthorId.load(author.id);
  }
}

When to use it

  • A list query whose items each resolve a relation, such as posts.author or orders.customer.
  • Collapse 101 SELECTs into 2 without changing the schema the client sees.
  • Avoid refetching the same user row once per comment in a long thread.
  • Batch calls to a slow upstream HTTP API the same way you batch SQL.

More examples

The version that issues 101 queries

Worth keeping next to the fixed version, because the fix changes nothing a client can see — same schema, same resolver signature. Turn on your ORM's query logging and run a nested query: if you see the same statement repeated with different parameters, this is what you are looking at.

Example · typescript
// src/authors/authors.resolver.ts — the shape to recognise, not to keep
import { Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Author } from './author.model.js';
import { Post } from '../posts/post.model.js';
import { AuthorsService } from './authors.service.js';
import { PostsService } from '../posts/posts.service.js';

@Resolver(() => Author)
export class AuthorsResolver {
  constructor(
    private readonly authors: AuthorsService,
    private readonly posts: PostsService,
  ) {}

  // query { authors(take: 100) { name posts { title } } }

  // 1 query: SELECT * FROM author LIMIT 100
  @Query(() => [Author])
  authors() {
    return this.authors.findAll({ skip: 0, take: 100 });
  }

  // 100 queries: SELECT * FROM post WHERE author_id = $1
  // One per author. Nothing here looks wrong in isolation, which is why this
  // survives code review and shows up as a latency graph instead.
  @ResolveField('posts', () => [Post])
  posts(@Parent() author: Author) {
    return this.posts.findByAuthor(author.id);
  }
}

Loaders on the GraphQL context instead

Choose this over Scope.REQUEST when you do not want request scope spreading up the injection chain — a request-scoped resolver is instantiated per request, along with everything it depends on. The trade is that loaders are now untyped context data, so the GraphQLLoaders interface is doing the work the DI container would otherwise do.

Example · typescript
// src/loaders/loaders.factory.ts
import { Injectable } from '@nestjs/common';
import DataLoader from 'dataloader';
import { PostsService } from '../posts/posts.service.js';
import { Post } from '../posts/post.model.js';

export interface GraphQLLoaders {
  postsByAuthor: DataLoader<number, Post[]>;
}

// A singleton factory that builds a fresh set of loaders on demand.
@Injectable()
export class LoadersFactory {
  constructor(private readonly posts: PostsService) {}

  create(): GraphQLLoaders {
    return {
      postsByAuthor: new DataLoader<number, Post[]>(async (authorIds) => {
        const all = await Promise.all(
          authorIds.map((id) => this.posts.findByAuthor(id)),
        );
        return all; // one entry per key, in key order
      }),
    };
  }
}

// src/app.module.ts (GraphQL section only)
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { LoadersFactory } from './loaders/loaders.factory.js';
import { LoadersModule } from './loaders/loaders.module.js';

@Module({
  imports: [
    GraphQLModule.forRootAsync<ApolloDriverConfig>({
      driver: ApolloDriver,
      imports: [LoadersModule], // must export LoadersFactory
      inject: [LoadersFactory],
      useFactory: (loaders: LoadersFactory) => ({
        autoSchemaFile: true,
        // `context` runs once per HTTP request, so this is per-request state
        // without making any resolver request-scoped.
        context: () => ({ loaders: loaders.create() }),
      }),
    }),
  ],
})
export class AppModule {}

// src/authors/author-posts.resolver.ts
import { Context, Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Author } from './author.model.js';
import { Post } from '../posts/post.model.js';
import { GraphQLLoaders } from '../loaders/loaders.factory.js';

@Resolver(() => Author)
export class AuthorPostsResolver {
  @ResolveField('posts', () => [Post])
  posts(@Parent() author: Author, @Context('loaders') loaders: GraphQLLoaders) {
    return loaders.postsByAuthor.load(author.id);
  }
}

A one-to-one loader with per-key errors

The many-to-one direction — Post.author, Comment.user — where a missing row is a real possibility. It shows the two options you will actually set (maxBatchSize, cacheKeyFn) and the per-key error channel, which is how you keep one bad id from blanking every other field in the response.

Example · typescript
// src/authors/author-by-id.loader.ts
import { Injectable, Scope } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import DataLoader from 'dataloader';
import { AuthorEntity } from './author.entity.js';

@Injectable({ scope: Scope.REQUEST })
export class AuthorByIdLoader {
  readonly loader = new DataLoader<number, AuthorEntity | Error>(
    async (ids) => {
      const rows = await this.authors.find({ where: { id: In([...ids]) } });
      const byId = new Map(rows.map((row) => [row.id, row]));
      // Returning an Error in a slot rejects that one load() promise and
      // leaves the other keys resolved. Throwing here would fail all of them.
      return ids.map((id) => byId.get(id) ?? new Error(`Author ${id} not found`));
    },
    {
      // Keeps a single IN clause from growing past what the database or the
      // driver will accept.
      maxBatchSize: 100,
      // Default is `key => key`; spell it out when keys are objects and two
      // equivalent objects must share a cache slot.
      cacheKeyFn: (id) => id,
    },
  );

  constructor(
    @InjectRepository(AuthorEntity)
    private readonly authors: Repository<AuthorEntity>,
  ) {}
}

Discussion

  • Be the first to comment on this lesson.