Solving N plus 1 with DataLoader

The N+1 Problem

The category field resolver above runs once per product. 50 products mean 1 query for products + 50 queries for categories. DataLoader collects all ids requested in the same tick and loads them in one batch query.

Example

A minimal DataLoader: load(id) calls in the same tick are batched into a single batchFn call.

class DataLoader<K, V> {
  private queue: { key: K; resolve: (v: V) => void }[] = [];
  constructor(private batchFn: (keys: K[]) => Promise<V[]>) {}
  load(key: K): Promise<V> {
    return new Promise((resolve) => {
      if (this.queue.length === 0) process.nextTick(() => this.dispatch());
      this.queue.push({ key, resolve });
    });
  }
  private async dispatch() {
    const batch = this.queue;
    this.queue = [];
    const keys = [...new Set(batch.map((b) => b.key))];
    const values = await this.batchFn(keys);
    const byKey = new Map(keys.map((k, i) => [k, values[i]]));
    batch.forEach((b) => b.resolve(byKey.get(b.key)!));
  }
}

const categories = new Map([[1, 'Audio'], [2, 'Mobiles'], [3, 'Wearables']]);
let queries = 0;
const loader = new DataLoader<number, string>(async (ids) => {
  queries++;
  console.log('SQL: SELECT * FROM categories WHERE id IN (' + ids.join(', ') + ')');
  return ids.map((id) => categories.get(id) ?? 'Unknown');
});

const products = [
  { name: 'Airdopes 141', categoryId: 1 }, { name: 'Redmi Note 13', categoryId: 2 },
  { name: 'Nord Buds 2', categoryId: 1 }, { name: 'ColorFit Pro', categoryId: 3 },
  { name: 'JBL Go 3', categoryId: 1 },
];

async function main() {
  const resolved = await Promise.all(products.map(async (p) => p.name + ' -> ' + (await loader.load(p.categoryId))));
  console.log(resolved.join('\n'));
  console.log('category queries:', queries, 'instead of', products.length);
}
main();

Output

SQL: SELECT * FROM categories WHERE id IN (1, 2, 3)
Airdopes 141 -> Audio
Redmi Note 13 -> Mobiles
Nord Buds 2 -> Audio
ColorFit Pro -> Wearables
JBL Go 3 -> Audio
category queries: 1 instead of 5

In NestJS

Create the loader per request (request-scoped provider or GraphQL context) so cached values never leak between users. The dataloader npm package provides the real implementation.

import DataLoader from 'dataloader';
import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.REQUEST })
export class CategoryLoader {
  constructor(private readonly prisma: PrismaService) {}

  readonly byId = new DataLoader<number, Category>(async (ids) => {
    const rows = await this.prisma.category.findMany({ where: { id: { in: [...ids] } } });
    const map = new Map(rows.map((r) => [r.id, r]));
    return ids.map((id) => map.get(id) ?? new Error(`Category ${id} not found`));
  });
}

// resolver: @ResolveField() category(@Parent() p: ProductModel) { return this.categoryLoader.byId.load(p.categoryId); }