Performance Best Practices
NestJS is built on top of Express (or Fastify), and most performance problems in production apps come not from the framework but from how you wire your providers, talk to your database, and serialize responses. The good news is that the same dependency-injection model that keeps your code clean also gives you precise control over caching, batching, and provider lifetimes. This page covers the highest-impact practices: keeping providers singleton, caching hot paths, streaming large payloads, batching database calls, and—most importantly—measuring before you optimize.
Prefer singleton scope; avoid request scope
By default every NestJS provider is a singleton: it is instantiated once and shared across the whole application. This is the fastest option because the DI container resolves the dependency graph a single time at bootstrap. When you mark a provider with Scope.REQUEST, Nest must instantiate a fresh instance—and the entire chain of providers that inject it—on every incoming request. That bubbles up the dependency tree and can quietly turn a controller into a per-request object factory.
import { Injectable, Scope } from '@nestjs/common';
// Fast: one instance for the lifetime of the app
@Injectable()
export class CatalogService {
getProducts() {
return this.repo.findAll();
}
}
// Slow: new instance (and dependent chain) per request — use only when truly needed
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
constructor() {}
}
If you only need the current request to read a user id or correlation header, reach for the durable AsyncLocalStorage pattern or @nestjs/core’s REQUEST injection sparingly rather than making whole services request-scoped.
Request-scoped providers cannot be used inside lifecycle hooks like
onModuleInit, and they disable certain optimizations. Treat them as a last resort, not a default.
Cache hot paths
Repeated reads of data that rarely changes—config, reference tables, computed aggregates—are the easiest win. NestJS ships a cache abstraction via @nestjs/cache-manager that works with an in-memory store or Redis.
npm install @nestjs/cache-manager cache-manager
import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
@Module({
imports: [
CacheModule.register({
ttl: 30_000, // milliseconds
max: 1000, // max cached items (in-memory store)
isGlobal: true,
}),
],
})
export class AppModule {}
You can cache an entire GET route declaratively with the interceptor, or cache selectively inside a service:
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
@Injectable()
export class ExchangeRateService {
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
async getRate(pair: string): Promise<number> {
const cached = await this.cache.get<number>(`rate:${pair}`);
if (cached !== undefined) return cached;
const rate = await this.fetchFromUpstream(pair);
await this.cache.set(`rate:${pair}`, rate, 60_000);
return rate;
}
private async fetchFromUpstream(pair: string): Promise<number> {
// real HTTP call to a pricing provider
return 1.0847;
}
}
Stream large payloads
Building a multi-megabyte response in memory and serializing it as JSON spikes heap usage and latency. For large files or big query results, return a stream so Node pipes bytes straight to the client.
import { Controller, Get, StreamableFile } from '@nestjs/common';
import { createReadStream } from 'node:fs';
import { join } from 'node:path';
@Controller('reports')
export class ReportsController {
@Get('export')
download(): StreamableFile {
const file = createReadStream(join(process.cwd(), 'reports/export.csv'));
return new StreamableFile(file, {
type: 'text/csv',
disposition: 'attachment; filename="export.csv"',
});
}
}
For database exports, prefer a cursor or QueryStream from your driver over loading every row into an array.
Batch and shape database calls
The classic N+1 problem—issuing one query per item in a loop—is the single most common cause of slow endpoints. Batch related lookups with a single IN (...) query, or use a per-request DataLoader to coalesce calls.
import { Injectable } from '@nestjs/common';
import DataLoader from 'dataloader';
@Injectable()
export class UserLoader {
readonly byId = new DataLoader<number, User>(async (ids) => {
const users = await this.repo.find({ where: { id: In([...ids]) } });
const map = new Map(users.map((u) => [u.id, u]));
return ids.map((id) => map.get(id) ?? null);
});
constructor(private readonly repo: UserRepository) {}
}
Also select only the columns you need, add indexes for your query predicates, and paginate list endpoints instead of returning unbounded result sets.
| Anti-pattern | Faster approach |
|---|---|
Query inside a for loop (N+1) | Single IN (...) query or DataLoader |
SELECT * | Project only required columns |
| Returning all rows | Cursor/offset pagination |
await calls in series | Promise.all for independent work |
Parallelize independent async work
await-ing operations one after another forces them to run serially even when they do not depend on each other. Use Promise.all to overlap independent I/O.
async function buildDashboard(userId: number) {
const [profile, orders, notifications] = await Promise.all([
profileService.find(userId),
orderService.recent(userId),
notificationService.unread(userId),
]);
return { profile, orders, notifications };
}
Profile before optimizing
Never guess at the bottleneck. Capture real numbers first, then optimize the slowest path.
node --prof dist/main.js # generate a V8 CPU profile
node --prof-process isolate-*.log # human-readable summary
Output:
ticks total nonlib name
9521 42.1% 58.3% LazyCompile: *serializeEntity transform.js:88
3104 13.7% 19.0% LazyCompile: *query orm/driver.js:204
Add lightweight timing or use the built-in Logger around suspicious paths, and watch event-loop lag in production with perf_hooks.monitorEventLoopDelay().
Best Practices
- Keep providers singleton; reserve
Scope.REQUESTfor genuine per-request state and preferAsyncLocalStoragefor context. - Cache slow, read-heavy paths with
@nestjs/cache-manager, backing it with Redis once you run more than one instance. - Stream large files and exports with
StreamableFileinstead of buffering them in memory. - Eliminate N+1 queries with batched
IN (...)lookups or DataLoader, and always paginate list endpoints. - Run independent async I/O concurrently with
Promise.allrather than awaiting in series. - Switch to the Fastify adapter for throughput-sensitive services when you do not need Express-specific middleware.
- Measure with
--prof, clinic.js, or event-loop monitoring before changing code—optimize the proven hot path, not a hunch.