Review & Verify Updated 2026-09 10 min read View as Markdown

The performance traps in generated NestJS code

Two Nest-specific problems dominate: N+1 queries through the ORM, and request-scoped providers that quietly make your entire dependency graph rebuild on every request.

Nest runs on Node, so everything in the JavaScript performance page applies — event loop blocking, unbounded concurrency, memory leaks, sequential awaits. Read that one first; it is the larger category.

What is specific to Nest is two things that generated code produces routinely and that no linter flags.

1. Request-scoped provider contagion#

The Nest performance problem people discover last and regret most.

ts
@Injectable({ scope: Scope.REQUEST })     // looks harmless
export class RequestContext {
  constructor(@Inject(REQUEST) private readonly req: Request) {}
  get tenantId() { return this.req.user?.tenantId; }
}

Scope propagates upward. Anything that injects a request-scoped provider becomes request-scoped, and so does anything injecting that, all the way to the controller. One innocuous provider can make your entire dependency graph reconstruct on every request.

RequestContext (REQUEST)
  -> AuditService        becomes REQUEST
  -> OrdersService       becomes REQUEST
  -> OrdersController    becomes REQUEST

You are now constructing four objects per request instead of zero, and you have lost the ability to use those services in a scheduled job or a queue consumer, because there is no request to inject.

The fix is AsyncLocalStorage, which gives you per-request values with singleton providers:

src/context/request-context.ts
import { AsyncLocalStorage } from 'node:async_hooks';

export interface RequestContext { tenantId: string; requestId: string; userId?: string; }
export const requestContext = new AsyncLocalStorage<RequestContext>();
src/context/context.middleware.ts
@Injectable()
export class ContextMiddleware implements NestMiddleware {
  use(req: Request, _res: Response, next: NextFunction) {
    requestContext.run(
      { tenantId: (req as any).user?.tenantId, requestId: randomUUID() },
      () => next(),
    );
  }
}

Now any singleton can call requestContext.getStore() and get the current request's values. nestjs-cls wraps this pattern if you want it as a module.

Find it with:

shell
grep -rn 'Scope.REQUEST\|Scope.TRANSIENT' src

In a healthy Nest application that returns nothing, or one deliberate case with a comment.

2. N+1 queries#

The most expensive item in practice, in any ORM.

ts
const orders = await this.prisma.order.findMany({ where: { userId } });
for (const order of orders) {
  order.items = await this.prisma.orderItem.findMany({ where: { orderId: order.id } });
}

Correct. Passes every test, because the test has three orders. In production with a hundred orders per user that is 101 round trips.

ts
const orders = await this.prisma.order.findMany({
  where: { userId },
  include: { items: true },              // one query, or two with a join strategy
});

TypeORM equivalents are relations: ['items'] or an explicit leftJoinAndSelect. The trap in TypeORM is lazy relations — a property that issues a query when accessed, which makes an N+1 invisible in the code because there is no await on the line that causes it.

Catch it with a query-count test. It is the only reliable defence:

ts
it('lists orders in at most two queries', async () => {
  const spy = countQueries(prisma);
  await request(app.getHttpServer()).get('/orders').set(auth).expect(200);
  expect(spy.count).toBeLessThanOrEqual(2);
});

Prisma exposes $on('query'); TypeORM has a logger you can count through. Twenty lines of test helper, and it catches the single most expensive class of regression in a Nest service.

Serialization#

3. ClassSerializerInterceptor on large payloads#

class-transformer uses reflection and is not cheap. On a list endpoint returning ten thousand rows, plainToInstance can dominate the response time.

For large collections, project the shape in the query instead of transforming after:

ts
return this.prisma.order.findMany({
  where: { userId },
  select: { id: true, total: true, createdAt: true },    // only what you serialise
});

That is faster on both sides: less data from the database, no transformation pass. Keep the serializer for endpoints where @Exclude() is doing security work — see the security page — and be deliberate about the ones where it is not needed.

4. Returning entities directly#

Returning an ORM entity means serialising every loaded relation, including ones you did not intend to expose. It is a payload-size problem and a disclosure problem at once. Return an explicit shape.

Database plumbing#

5. Connection pool sizing#

?connection_limit=10        # Prisma
extra: { max: 10 }          # TypeORM

The default is often wrong in both directions. Too small and requests queue behind a pool that is idle-waiting; too large and you exhaust the database's own connection limit — which fails at the database rather than in your app, and looks like an outage rather than a config problem.

The arithmetic that matters: replicas × pool_size must stay under your database's max_connections, with headroom for migrations and admin tools. Four replicas at a pool of 25 against a Postgres configured for 100 leaves you nothing.

Use a connection pooler (PgBouncer, or your provider's) for anything serverless, where instance count is unbounded by design.

6. Missing indexes on foreign keys and filters#

Not Nest-specific, but generated schemas frequently declare relations without indexing the foreign key. Every findMany({ where: { orderId } }) then becomes a sequential scan.

prisma
model OrderItem {
  orderId String
  order   Order  @relation(fields: [orderId], references: [id])
  @@index([orderId])                      // generated schemas often omit this
}

Run EXPLAIN ANALYZE on your three most common queries once. It is twenty minutes and it usually finds something.

Lifecycle#

7. Missing shutdown hooks#

ts
app.enableShutdownHooks();

Without it, onModuleDestroy never runs: connections are not closed, in-flight requests are cut off, and every redeploy leaks database connections until the pool is exhausted. It presents as a slow degradation over days, which makes it hard to attribute.

8. Work in the constructor#

ts
@Injectable()
export class ReportService {
  constructor(private readonly prisma: PrismaService) {
    this.warmCache();        // fires during module construction, unawaited
  }
}

Constructors cannot be async, so generated warm-up code either blocks module initialisation or floats an unhandled promise. Use onModuleInit, which Nest awaits.

9. No health check, or one that queries the database#

A health endpoint that hits the database on every probe adds load proportional to your probe frequency times your replica count. Use @nestjs/terminus and separate liveness (is the process alive) from readiness (can it serve), with only readiness touching dependencies.

Caching#

ts
@UseInterceptors(CacheInterceptor)
@CacheTTL(30)
@Get('popular')
popular() { return this.products.popular(); }

Two things to get right, both of which generated code gets wrong:

  • Use a shared store (Redis) if you run more than one instance. The default in-memory cache means each replica has its own, so your hit rate divides by your replica count and users see inconsistent data.
  • Never cache an authenticated response with the default key, which is derived from the URL. Two users hitting /me get each other's data. Override trackBy to include the user, or do not cache the endpoint.

That second one is a security bug wearing a performance costume, and it is a genuinely easy mistake to make.

Measuring#

shell
npx clinic doctor -- node dist/main.js      # which category of problem?
node --cpu-prof dist/main.js
autocannon -c 100 -d 30 http://localhost:3000/orders

Add an interceptor that logs slow requests with the query count, and you will find most of this page without a profiler:

ts
@Injectable()
export class SlowRequestInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    const started = Date.now();
    return next.handle().pipe(tap(() => {
      const ms = Date.now() - started;
      if (ms > 500) this.logger.warn({ url: ctx.switchToHttp().getRequest().url, ms }, 'slow');
    }));
  }
}

The two Nest-specific things

grep -rn 'Scope.REQUEST' src should return nothing, and every list endpoint should have a query-count test. Those two checks cover the problems that are specific to this framework; everything else is Node performance.

Common questions#

Is request scope ever the right answer?#

Rarely. It is defensible for something that genuinely must be constructed per request and cannot use AsyncLocalStorage — a per-tenant database connection, say. Even then, keep it at a leaf of the graph so the contagion does not reach your controllers, and comment why.

Prisma or TypeORM for performance?#

Close enough that it is not the deciding factor. Prisma's generated queries are predictable and its include behaviour is explicit; TypeORM's lazy relations make accidental N+1 easier to write. If you use TypeORM, avoid lazy relations.

How do I count queries in a test?#

Prisma exposes a query event you can subscribe to; TypeORM lets you supply a custom logger. Either way it is about twenty lines of test helper, and it is the highest-value performance test in a Nest codebase because N+1 is both the most common and the most expensive regression.

Does the caching interceptor work across replicas?#

Only with a shared store configured. The default is per-process memory, so with four replicas you get a quarter of the hit rate and users see different cached values depending on which instance they reach.

Get the NestJS agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for NestJS. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.