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.
@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 REQUESTYou 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:
import { AsyncLocalStorage } from 'node:async_hooks';
export interface RequestContext { tenantId: string; requestId: string; userId?: string; }
export const requestContext = new AsyncLocalStorage<RequestContext>();@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:
grep -rn 'Scope.REQUEST\|Scope.TRANSIENT' srcIn 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.
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.
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:
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:
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 } # TypeORMThe 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.
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#
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#
@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#
@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
/meget each other's data. OverridetrackByto 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#
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/ordersAdd an interceptor that logs slow requests with the query count, and you will find most of this page without a profiler:
@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.
AGENTS.md now — no email needed.