# The performance traps in generated NestJS code

> Source: https://learn-nestjs.com/review/performance/
> Part of Learn NestJS, free to read.

Nest runs on Node, so [everything in the JavaScript performance page](https://learn-javascript.org/review/performance/) 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.

```text
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:

```ts 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>();
```

```ts 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:**

```bash
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](/review/security/) — 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

```text
?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

```bash
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');
    }));
  }
}
```

:::verdict 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](https://learn-javascript.org/review/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.
