Performance Optimization
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2, @nestjs/cli 12.0.8), TypeScript 6 (6.0.x), Node.js 24 LTS (24.16.0 for the measurements), Express 5.2 and Fastify 5.12, and was checked on 2026-10-02. The code examples use a Nest 12 project (ESM template, Vitest and supertest), and the numbers on the page were measured on one laptop (Apple M5, 10 cores, 32 GB, macOS, load generator on the same machine, other work running at the same time). Treat them as indicative: they show the direction and rough size of an effect, never what your application will do. The tools used are autocannon 8.0 and Artillery 2.0.34, PM2 7.0.4 (in cluster mode), and @swc/core 1.16 and @rspack/core 2.2 for the build timings. Clinic.js 13 and k6 are covered too, with the limits stated where they apply.
Performance work in a NestJS service is mostly not about NestJS. The framework adds a thin layer (dependency injection, guards, interceptors, pipes) on top of an HTTP server, and for a typical handler that waits on a database the time goes to the query, the network and the event loop, not to the framework. So the order of this page is: measure first, then the framework-level levers that do matter (HTTP adapter, request scope, caching), then the data and transport levers (queries, compression, streaming), then how to scale out and how to start faster. In the Bookshelf application that grows across this section, the catalogue endpoints are the hot path and the examples use them or small stand-ins.
| Lever | What it changes | Where on this page |
|---|---|---|
Measure first |
Tells you whether the process is CPU-bound, I/O-bound or blocked |
|
Fastify adapter |
Cost of the HTTP layer per request |
|
Avoid request scope on hot paths |
Per-request instantiation and garbage |
|
Caching |
Work repeated for the same answer |
|
Indexes, N+1, pagination |
Database time per request |
|
Compression, streaming |
Bytes on the wire and memory per response |
|
node:cluster, PM2, containers |
Use of all cores, resilience |
|
SWC, Rspack, lazy modules, standalone |
Build time and start-up time |
Build speed: tsc, SWC and Rspack, Cold starts, standalone applications and lazy-loading modules |
Measure first: the event loop
A Node.js process runs your JavaScript on one thread, the event loop. Every handler, guard, interceptor and pipe of every request shares it, so one synchronous handler that computes for 300 ms makes every other request on that process wait 300 ms, however fast the framework is. The most useful single number for a Nest service is therefore the event-loop delay: how late the loop is in running a timer it scheduled. Node’s perf_hooks module exposes it as monitorEventLoopDelay(). The provider below enables it with the module lifecycle (see Lifecycle Events and Shutdown) and the controller exposes it, together with a deliberately blocking handler to show what a spike looks like.
import { monitorEventLoopDelay } from 'node:perf_hooks';
import { Controller, Get, Injectable, OnModuleDestroy, OnModuleInit, Query } from '@nestjs/common';
@Injectable()
export class EventLoopMonitor implements OnModuleInit, OnModuleDestroy {
private histogram!: ReturnType<typeof monitorEventLoopDelay>;
onModuleInit(): void {
this.histogram = monitorEventLoopDelay({ resolution: 10 });
this.histogram.enable();
}
onModuleDestroy(): void {
this.histogram.disable();
}
snapshot() {
const ms = (ns: number) => Math.round(ns / 1e4) / 100; // nanoseconds -> ms, 2 decimals
const h = this.histogram;
return { p50: ms(h.percentile(50)), p99: ms(h.percentile(99)), max: ms(h.max) };
}
}
@Controller()
export class PerformanceController {
constructor(private readonly monitor: EventLoopMonitor) {}
@Get('debug/event-loop')
eventLoop() {
return this.monitor.snapshot();
}
// Anti-pattern: synchronous CPU work blocks every other request on this process.
@Get('blocking')
blocking(@Query('ms') ms = '100') {
const end = Date.now() + Number(ms);
while (Date.now() < end) {
/* busy loop */
}
return { blockedFor: Number(ms) };
}
}
The values are in milliseconds and cumulative since the process started (call reset() on the histogram to start a new window, for example once per metrics scrape). Even an idle process reports around 12 ms here, because the histogram’s own timer fires every 10 ms and the reported delay includes that interval: compare values with each other, not with zero. A server that has been idle, then serves GET /blocking?ms=300, reports:
{ "p50": 12.03, "p99": 15.93, "max": 15.93 }
{ "p50": 12.03, "p99": 300.68, "max": 300.68 }
Export the same numbers to your metrics system (see Health Checks and Observability) and alert on the p99. If the p99 is high, the process is CPU-bound or blocked and the fix is in code (see Profiling); if it is low while requests are slow, the time is going to I/O (Database indexes and N+1, outbound calls) and a profiler of JavaScript will not show it.
The Fastify adapter
By default Nest runs on Express. The official performance chapter states that Fastify is significantly faster than Express (nearly twice the benchmark results) while Express stays the default because of its ecosystem, and that you can switch because Nest is platform-independent. The switch is one adapter at bootstrap and the rest of the application, its controllers, guards, pipes and providers, does not change. The details, the differences in middleware, CORS, body limits and @Res() handling are on Fastify and HTTP Platforms; this section shows the switch and measures it.
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter, NestExpressApplication } from '@nestjs/platform-express';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { BooksModule } from './books.js';
// PLATFORM=fastify switches the adapter; nothing else in the application changes.
async function bootstrap() {
const port = Number(process.env.PORT ?? 3000);
if (process.env.PLATFORM === 'fastify') {
const app = await NestFactory.create<NestFastifyApplication>(BooksModule, new FastifyAdapter(), {
logger: ['error'], // fewer log lines on the hot path
});
await app.listen(port, '0.0.0.0'); // Fastify binds to localhost only by default
} else {
const app = await NestFactory.create<NestExpressApplication>(BooksModule, new ExpressAdapter(), {
logger: ['error'],
});
await app.listen(port);
}
}
await bootstrap();
The module is a plain controller and service returning one of 20 in-memory books as JSON (GET /books/3), run on both platforms through a Vitest e2e spec that sends the same request through supertest on Express and through Fastify’s inject() and asserts identical bodies. Source: NestJS: Performance (Fastify).
To compare, build once, start each platform on its own port, and drive it with autocannon from the same machine, alternating the order and discarding a warm-up:
PLATFORM=express PORT=4171 node dist/bootstrap.js &
npx autocannon -c 100 -w 4 -d 10 --warmup [ -c 10 -d 3 ] http://127.0.0.1:4171/books/3
| Platform | Requests per second, per round | p99 latency |
|---|---|---|
Express 5.2 ( |
72,700 / 69,900 / 65,700 |
2 ms |
Fastify 5.12 ( |
102,800 / 98,200 / 86,300 |
1 to 2 ms |
Fastify was about 1.3 to 1.5 times faster on this trivial handler, which agrees with the official claim in direction. Another series, with the load generator under more contention, produced 24,400 to 27,400 requests per second for Express and 33,100 to 40,000 for Fastify (the same ratio at a third of the absolute rate), and a series with a single load-generator thread (no -w) produced 28,000 to 32,000 against 32,000 to 37,000, where the generator, not the server, was the limit. Three lessons follow. Absolute numbers move by a factor of two or three with machine load, so only compare runs made back to back. The generator needs enough threads (-w) or it measures itself. And a 1.3 to 1.5 times gap on a handler that does nothing is an upper bound: when the handler waits 20 ms on PostgreSQL, the HTTP layer is a small part of the request and the platform choice barely moves latency. Move to Fastify when you have measured that the HTTP layer matters, accepting the porting checklist on the platforms page.
Source: NestJS: Performance (Fastify) and autocannon.
Avoid request-scoped providers on hot paths
A provider is a singleton by default: built once at bootstrap and shared. With Scope.REQUEST Nest builds a new instance for every request, and everything that depends on it, up to the controller, inherits the scope (scope bubbling, described on Injection Scopes). The official docs say a well-designed application with request-scoped providers should not see latency grow by more than about 5%, and recommend the default scope unless you need otherwise. The cost is easy to measure with a handler that does nothing, which isolates the instantiation: the same endpoint with a singleton provider and with a request-scoped provider.
import { Controller, Get, Injectable, Module, Scope } from '@nestjs/common';
export const constructed = { singleton: 0, scoped: 0 };
@Injectable() // default scope: built once and shared by every request
export class PriceListService {
constructor() {
constructed.singleton++;
}
priceOf(id: number): number {
return 10 + id;
}
}
@Controller('prices')
export class PricesController {
constructor(private readonly prices: PriceListService) {}
@Get()
find(): { price: number } {
return { price: this.prices.priceOf(1) };
}
}
@Module({ controllers: [PricesController], providers: [PriceListService] })
export class SingletonPricesModule {}
@Injectable({ scope: Scope.REQUEST }) // one instance per request
export class RequestPriceListService {
constructor() {
constructed.scoped++;
}
priceOf(id: number): number {
return 10 + id;
}
}
// No scope of its own, but it injects a request-scoped provider: Nest builds it per request too.
@Controller('prices')
export class ScopedPricesController {
constructor(private readonly prices: RequestPriceListService) {
constructed.scoped++;
}
@Get()
find(): { price: number } {
return { price: this.prices.priceOf(1) };
}
}
@Module({ controllers: [ScopedPricesController], providers: [RequestPriceListService] })
export class ScopedPricesModule {}
A spec sent five requests to each module and counted constructor calls: the singleton was constructed once, and the request-scoped graph ten times (two constructors, provider and controller, per request). The two modules were then run on Fastify and loaded with autocannon (-c 100 -w 4 -d 10), three rounds each:
| Scope | Requests per second, per round |
|---|---|
Default (singleton) |
63,700 / 79,000 / 83,500 |
Request-scoped provider (controller bubbles) |
45,900 / 45,800 / 47,300 |
On this empty handler the request-scoped graph cost roughly 40% of the throughput. That is far above the official 5% because there is no real work to dilute it; with realistic handlers the relative cost is smaller, but it never reaches zero, it grows with the number of providers that bubble, and the instances become garbage for every request. The practical rules: keep providers singleton, and keep request-scoped ones small and leaf-like. The usual reason for request scope is request-unique data such as the tenant or the current user. Node’s AsyncLocalStorage keeps that data per request while every provider stays a singleton, and Injection Scopes shows a verified middleware plus singleton version of it, and Logging uses the same mechanism for request ids. Durable providers, covered on the scopes page, cut the cost for multi-tenant graphs by sharing sub-trees per tenant instead of per request.
Caching
The cheapest request is the one you do not compute. There are three layers, and they compose.
In the application. @nestjs/cache-manager stores values or whole responses in memory or in Redis; Caching covers stores, TTLs, keys, invalidation and the traps in depth. The minimal use for a hot read is CacheInterceptor with a TTL. The handler below stands in for an expensive report (it counts how often it really runs), and the interceptor answers the second request from the cache.
import { Controller, Get, Injectable, Module, UseInterceptors } from '@nestjs/common';
import { CacheInterceptor, CacheModule, CacheTTL } from '@nestjs/cache-manager';
@Injectable()
export class ReportService {
calls = 0;
expensiveReport(): { generatedAt: number; calls: number } {
this.calls++; // stands in for a slow query
return { generatedAt: Date.now(), calls: this.calls };
}
}
@Controller()
export class ReportController {
constructor(private readonly report: ReportService) {}
@Get('report')
@UseInterceptors(CacheInterceptor)
@CacheTTL(30_000) // milliseconds
cachedReport() {
return this.report.expensiveReport();
}
}
@Module({
imports: [CacheModule.register()],
controllers: [ReportController],
providers: [ReportService],
})
export class ReportModule {}
Two requests returned X-Cache: MISS then X-Cache: HIT, and ReportService.calls was 1. An in-memory cache is per process: with several processes or containers each has its own copy, which is fine for read-mostly data with a short TTL and wrong for anything that must be consistent, where a shared store such as Redis is the answer (see Using every core: cluster, PM2 and containers).
On the wire. HTTP caching headers let browsers and CDNs reuse a response without reaching the service at all, and conditional requests let a client revalidate cheaply. @Header() sets the response header. Express also adds a weak ETag and answers If-None-Match with 304 Not Modified on its own; Fastify does not, and needs a plugin for that (such as @fastify/etag, not run here).
import { Controller, Get, Header, Module } from '@nestjs/common';
@Controller('catalogue')
export class CatalogueHttpController {
// Lets browsers and CDNs reuse the response for 60 s and revalidate for another 5 minutes.
@Get()
@Header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
list() {
return Array.from({ length: 20 }, (_, i) => ({ id: i + 1, title: `Book ${i + 1}` }));
}
}
@Module({ controllers: [CatalogueHttpController] })
export class HttpCacheModule {}
On Express the first response carried Cache-Control: public, max-age=60, stale-while-revalidate=300 and an ETag (W/"21f-…"), and a repeat request with If-None-Match set to that value got 304 with an empty body. On Fastify the Cache-Control header was identical and no ETag was set. Never mark per-user responses public.
In the database and in the client. A materialised or pre-computed result, a read replica and a Redis cache in front of a service call are all the same idea at other layers; the Bookshelf catalogue uses Redis behind CacheInterceptor on the read side (see Caching).
Source: NestJS: Caching, Auto-caching responses and MDN: HTTP caching.
Database indexes and N+1
In an I/O-bound service the database is usually the largest share of a request. Two mistakes cause most of the damage: a query that scans a table because no index fits, and the N+1 pattern, one query for a list and then one more per row. Both are invisible in a unit test and obvious under load. This section uses TypeORM with SQLite in memory so that it runs anywhere; the shape is the same on PostgreSQL (see Data Overview and TypeORM for the Nest setup).
import { Injectable, Module } from '@nestjs/common';
import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm';
import { Column, Entity, Index, ManyToOne, OneToMany, PrimaryGeneratedColumn, Repository } from 'typeorm';
import type { Logger as TypeOrmLogger, Relation } from 'typeorm';
@Entity()
export class Writer {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@OneToMany(() => Volume, (volume) => volume.writer)
volumes: Relation<Volume[]>;
}
@Entity()
@Index(['isbn'], { unique: true }) // lookups by isbn use this index
@Index(['title']) // a plain index for filtering and sorting by title
export class Volume {
@PrimaryGeneratedColumn()
id: number;
@Column()
isbn: string;
@Column()
title: string;
@ManyToOne(() => Writer, (writer) => writer.volumes, { nullable: false })
writer: Relation<Writer>;
}
// A TypeORM logger that only counts statements, to make N+1 visible.
export class QueryCounter implements TypeOrmLogger {
count = 0;
logQuery() {
this.count++;
}
logQueryError() {}
logQuerySlow() {}
logSchemaBuild() {}
logMigration() {}
log() {}
}
export const counter = new QueryCounter();
@Injectable()
export class CatalogueService {
constructor(
@InjectRepository(Volume) private readonly volumes: Repository<Volume>,
@InjectRepository(Writer) private readonly writers: Repository<Writer>,
) {}
// N+1: one query for the volumes, then one more per volume.
async listWithWriterNaive(): Promise<{ title: string; writer: string }[]> {
// loadRelationIds returns the foreign key in place of the related entity
const volumes = await this.volumes.find({ loadRelationIds: true });
const result: { title: string; writer: string }[] = [];
for (const volume of volumes) {
const writerId = volume.writer as unknown as number;
const writer = await this.writers.findOneByOrFail({ id: writerId });
result.push({ title: volume.title, writer: writer.name });
}
return result;
}
// One query: the relation is loaded with a join.
async listWithWriter(): Promise<{ title: string; writer: string }[]> {
const volumes = await this.volumes.find({ relations: { writer: true } });
return volumes.map((v) => ({ title: v.title, writer: v.writer.name }));
}
// Also one query, and only the columns that are needed.
listWithWriterLean(): Promise<{ title: string; writer: string }[]> {
return this.volumes
.createQueryBuilder('v')
.innerJoin('v.writer', 'w')
.select(['v.title AS title', 'w.name AS writer'])
.getRawMany();
}
// Paginated: never return an unbounded list.
page(skip: number, take: number): Promise<Volume[]> {
return this.volumes.find({ order: { title: 'ASC' }, skip, take });
}
findByIsbn(isbn: string): Promise<Volume | null> {
return this.volumes.findOneBy({ isbn });
}
}
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'better-sqlite3',
database: ':memory:',
entities: [Writer, Volume],
synchronize: true, // fine for a demo; use migrations in production
logger: counter,
logging: ['query'],
}),
TypeOrmModule.forFeature([Writer, Volume]),
],
providers: [CatalogueService],
exports: [CatalogueService, TypeOrmModule],
})
export class CatalogueModule {}
A spec inserted 10 writers with 100 volumes each and counted the statements sent by each method:
{ rows: 1000, naiveQueries: 1001, joinedQueries: 1, leanQueries: 1 }
The naive loop sent 1,001 statements, the join and the query builder one each. Against an in-memory database that costs little, but over a network each statement pays a round trip, so the loop’s latency grows with the number of rows and the connection pool is held for the whole loop. Count statements in tests (the counter above, or a logger assertion) so that a regression fails the build. The same applies to GraphQL resolvers, where DataLoader batches the per-row lookups (see Performance and N+1 in GraphQL).
Indexes are checked with the database’s own plan output. SQLite’s EXPLAIN QUERY PLAN, run through the same DataSource (ds.query(…)), reported:
WHERE isbn = 'isbn-3-7' -> SEARCH volume USING INDEX (isbn=?)
WHERE id + 0 = 7 AND isbn || '' = 'isbn-3-7' -> SCAN volume
WHERE title LIKE '%7' -> SCAN volume
An equality on an indexed column is a SEARCH using the index. Wrapping the column in an expression, or a LIKE with a leading wildcard, defeats the index and the plan is a full SCAN. On PostgreSQL use EXPLAIN (ANALYZE, BUFFERS) and read the node types; Diagnosing Slow Queries explains how to read them and when an index helps. Add indexes for the columns you filter, join and sort on, remember that every index slows writes and takes space, and always paginate lists (skip/take above, or keyset pagination, see Pagination Strategies).
Compression
Compression trades CPU for bytes. For JSON it is usually a good trade on the wire, and a poor one inside a Node.js process that is already busy. The official guidance, which this section follows, is that high-traffic production sites should offload compression to a reverse proxy such as NGINX and not enable compression middleware in the application. When the application must compress, Express uses the compression middleware (app.use(compression())) and Fastify uses @fastify/compress, registered with await app.register(…) on a NestFastifyApplication. Both are covered with their options on MVC, Static Files and Compression.
A Fastify spec registered @fastify/compress restricted to gzip and deflate (faster than Brotli) and requested a 50,000-element JSON array:
import compression from '@fastify/compress';
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
await app.register(compression, { encodings: ['gzip', 'deflate'] });
identity: 1,677,789 bytes
gzip: 240,043 bytes (Content-Encoding: gzip)
Repetitive JSON compresses well (about 7 times here); your data will differ. Do not compress already compressed formats (images, video, archives), keep tiny responses uncompressed, and put the compression where it can use its own CPU, in the proxy or the CDN.
Source: NestJS: Compression.
Streaming
A handler that builds a large array and returns it holds the whole array, and then the whole serialized string, in memory at once, and the client receives nothing until it is all built. Streaming sends the first bytes immediately and keeps memory flat. Nest streams when a handler returns a StreamableFile wrapping a Readable, on both platforms; File Upload and Streaming covers files, headers and error behaviour. For a large export, newline-delimited JSON (one object per line) can be produced from an async generator:
import { Readable } from 'node:stream';
import { Controller, Get, Header, StreamableFile } from '@nestjs/common';
@Controller()
export class ExportController {
// One JSON document per line, produced lazily: memory stays flat however many rows there are.
@Get('export.ndjson')
@Header('Content-Type', 'application/x-ndjson')
exportAll(): StreamableFile {
async function* rows() {
for (let id = 1; id <= 50_000; id++) {
yield JSON.stringify({ id, title: `Book ${id}` }) + '\n';
}
}
return new StreamableFile(Readable.from(rows()));
}
// The same data as one buffered JSON array, for comparison.
@Get('export.json')
exportBuffered() {
return Array.from({ length: 50_000 }, (_, i) => ({ id: i + 1, title: `Book ${i + 1}` }));
}
}
On Fastify the NDJSON response arrived with Transfer-Encoding: chunked, no Content-Length, and 50,000 lines. In a real exporter the generator reads from a database cursor or a stream, so neither the rows nor the output ever exist as one array. CacheInterceptor does not store StreamableFile responses, and ClassSerializerInterceptor does not touch them, so choose per endpoint between caching a small JSON answer and streaming a large one.
Profiling
When the event-loop delay says the process is busy, a CPU profile says where. Node ships three profilers, and none needs a Nest-specific tool. The handler below is deliberately wasteful, so the profile has an obvious answer: it hashes the same payload 2,000 times per request.
import { createHash } from 'node:crypto';
import { Controller, Get, Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
// A deliberately wasteful handler: it re-hashes the same payload many times.
function slugify(input: string): string {
let digest = input;
for (let i = 0; i < 2000; i++) {
digest = createHash('sha256').update(digest).digest('hex');
}
return digest.slice(0, 12);
}
@Controller('slug')
export class SlugController {
@Get()
slug() {
return { slug: slugify('Dune') };
}
}
@Module({ controllers: [SlugController] })
class AppModule {}
const app = await NestFactory.create(AppModule, { logger: ['error'] });
app.enableShutdownHooks(); // lets SIGINT/SIGTERM end the process normally so the profile is written
await app.listen(Number(process.env.PORT ?? 3000));
node --cpu-prof writes a V8 CPU profile (.cpuprofile) when the process exits. Start the built application with the flag, send load, then stop it with Ctrl+C (the enableShutdownHooks() call above matters here: without it a signal can end the process before the profile is written).
node --cpu-prof --cpu-prof-dir=./profiles dist/profiling-main.js &
npx autocannon -c 10 -d 4 http://127.0.0.1:3000/slug
kill -INT %1
The file loads in the Chrome DevTools Performance and Memory panels and in other .cpuprofile viewers. To see what it contains without a viewer, this script sums the sampled time per function:
import { readFileSync } from 'node:fs';
// Sum the sampled time per function from a .cpuprofile and print the top entries.
const profile = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const byId = new Map(profile.nodes.map((n) => [n.id, n]));
const self = new Map();
profile.samples.forEach((id, i) => {
const { functionName, url, lineNumber } = byId.get(id).callFrame;
const key = `${functionName || '(anonymous)'} ${url.split('/').slice(-2).join('/')}:${lineNumber + 1}`;
self.set(key, (self.get(key) ?? 0) + profile.timeDeltas[i]);
});
const total = [...self.values()].reduce((a, b) => a + b, 0);
for (const [key, us] of [...self].sort((a, b) => b[1] - a[1]).slice(0, 5)) {
console.log(`${((us / total) * 100).toFixed(1).padStart(5)} % ${key}`);
}
32.9 % (idle) :0
19.5 % Hash crypto/hash:90
17.3 % slug performance-optimization/profiling-main.js:21
12.8 % (garbage collector) :0
10.8 % update crypto/hash:134
Outside idle time, the hash constructor, the handler, the garbage collector and update() account for almost all of the busy time: the culprit is a loop that allocates a hash object 2,000 times per request. Fix the algorithm (hash once, cache the result, or move the work off the request path), not the framework.
node --prof is the older V8 tick profiler. It writes an isolate-*.log file that node --prof-process turns into a text report with a [JavaScript] table, a [Shared libraries] table and a bottom-up view. On the same program the report began with:
Statistical profiling result from isolate-0x...-v8.log, (2300 ticks, 34 unaccounted, 0 excluded).
[JavaScript]:
ticks total nonlib name
32 1.4% 1.6% JS: *Hash node:internal/crypto/hash:90:14
20 0.9% 1.0% JS: +slug file:///.../profiling-main.js:21:9
12 0.5% 0.6% JS: *update node:internal/crypto/hash:134:40
--inspect attaches a debugger and profiler over the DevTools protocol. nest start --debug runs the application with the --inspect flag (alias -d, optional host:port), and plain node --inspect=127.0.0.1:9339 dist/main.js prints Debugger listening on ws://127.0.0.1:9339/…, which Chrome (chrome://inspect) or an IDE can attach to. Use the profiler tab to record a live CPU profile, and the Memory tab for heap snapshots when you chase a leak. Never expose the inspector port beyond localhost: it gives full control of the process. The debugging and tooling side is on Debugging and Devtools.
nest start --debug # runs the application with the --inspect flag
node --inspect=127.0.0.1:9339 dist/main.js
Clinic.js (clinic doctor, clinic flame, clinic bubbleprof) is a suite that automates these steps: it runs your application under instrumentation, drives load, and produces an HTML report with diagnosis and recommendations. The documented flow is clinic doctor — node dist/main.js, apply load with autocannon, then stop the server so the report is generated. With clinic 13.0.0 on Node.js 24.16 (macOS), clinic doctor and clinic bubbleprof install without errors but exit with status 0, print only a deprecation warning and produce no report, so no Clinic.js output appears on this page and the commands are untested here. --cpu-prof and --prof above cover the same ground without extra tooling.
For a request-level view in production rather than a profile of one process, use traces and metrics (see Health Checks and Observability and Logging).
Load testing
A load test answers a different question from a profile: how does the service behave as traffic grows, and where does it break. Run it against a build that looks like production (NODE_ENV=production, a realistic database, the same container limits), from a different machine or at least a different process, and compare percentiles, not averages. Warm up first, change one thing at a time, and use a fixed arrival rate when you want to know what latency users see at a given load: a fixed number of connections slows its own request rate when the server slows down, which hides the problem.
autocannon is a fast HTTP benchmarking tool written in Node.js, and the one used for the numbers above. The flags used on this page are -c connections, -d duration in seconds, -w worker threads, -W/--warmup for a warm-up that is not counted, -p pipelining, -R a fixed overall request rate, -m, -b and -H for method, body and headers, and -j for JSON output. It also has a programmatic API, which suits a CI gate:
import autocannon from 'autocannon';
// Programmatic use: run a short benchmark and fail a CI job when p99 latency is too high.
const result = await autocannon({
url: process.env.TARGET ?? 'http://127.0.0.1:3000/books/3',
connections: 100,
duration: 10,
workers: 4,
warmup: { connections: 10, duration: 3 }, // not counted in the results
});
console.log(`${Math.round(result.requests.average)} req/s, p99 ${result.latency.p99} ms, errors ${result.errors}, non-2xx ${result.non2xx}`);
if (result.errors > 0 || result.non2xx > 0 || result.latency.p99 > 50) process.exitCode = 1;
Run against the Fastify build, it printed 100175 req/s, p99 2 ms, errors 0, non-2xx 0 and exited 0. Source: autocannon.
Artillery describes a test as YAML: phases with arrival rates, and scenarios made of requests. The file below ramps from 20 to 200 new virtual users per second against the two catalogue endpoints, and its ensure section turns thresholds into a pass or fail exit code for CI (the ensure plugin must be enabled, as shown).
config:
target: http://127.0.0.1:4160
phases:
- name: warm up
duration: 5
arrivalRate: 20
- name: steady load
duration: 10
arrivalRate: 200
plugins:
ensure: {}
ensure:
thresholds:
- http.response_time.p99: 100
maxErrorRate: 1
scenarios:
- name: browse books
flow:
- get:
url: /books
- get:
url: /books/{{ $randomNumber(1, 20) }}
Run with npx artillery run books-load.yml. Against the Fastify build it sends 4,200 requests (2,100 virtual users, two requests each), all 200, with a p99 response time of 1 ms here, and exits 0. With the threshold tightened to http.response_time.p99: 0 Artillery prints fail: http.response_time.p99 < 0 and exits 1, which is the behaviour a pipeline needs. Without the plugins: ensure lines the thresholds are ignored and the exit code stays 0, so keep them. Source: Artillery documentation and Artillery: ensure.
k6 scripts are JavaScript run by the k6 binary (not an npm package). The script below uses an arrival-rate executor, so the request rate stays fixed, and thresholds that fail the load test:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
scenarios: {
steady: {
executor: 'constant-arrival-rate', // fixed request rate, not a fixed number of users
rate: 200,
timeUnit: '1s',
duration: '30s',
preAllocatedVUs: 50,
maxVUs: 200,
},
},
thresholds: {
http_req_failed: ['rate<0.01'], // fewer than 1% errors
http_req_duration: ['p(99)<100'], // 99% of requests under 100 ms
},
};
export default function () {
const base = __ENV.BASE_URL || 'http://127.0.0.1:3000';
const res = http.get(`${base}/books/${1 + Math.floor(Math.random() * 20)}`);
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(0.1);
}
Unlike the other examples, the k6 script is untested beyond a Node.js syntax check (node --check), because it needs the native k6 binary: run it with k6 run books.k6.mjs and adjust the options in the k6 documentation before relying on it. Source: k6 documentation and k6: Thresholds.
Whichever tool you use, record the environment (instance size, Node.js version, database state), look at the service’s own event-loop delay and the database’s load during the load test, and treat a run on a laptop as a smoke test, not a capacity number.
Using every core: cluster, PM2 and containers
One Node.js process uses one core for JavaScript. A machine with 10 cores serving one process leaves nine idle, so a service needs several processes, and there are three ways to get them.
node:cluster. The built-in module forks workers that share one listening port and distributes connections between them. A Nest entry point that does this keeps the worker code (the normal bootstrap) separate from the primary process (fork, replace dead workers):
import cluster from 'node:cluster';
import { availableParallelism } from 'node:os';
import { NestFactory } from '@nestjs/core';
import { WhoModule } from './pid.js';
async function startWorker() {
const app = await NestFactory.create(WhoModule, { logger: ['error'] });
app.enableShutdownHooks(); // SIGTERM drains and closes the worker
await app.listen(Number(process.env.PORT ?? 3000));
}
if (cluster.isPrimary) {
const workers = Number(process.env.WORKERS ?? availableParallelism());
for (let i = 0; i < workers; i++) cluster.fork({ WORKER: String(i) });
// Replace a worker that dies, unless the primary itself is stopping.
cluster.on('exit', (worker, code) => {
if (code !== 0) cluster.fork();
});
} else {
await startWorker();
}
With WORKERS=3, eight requests to GET /who (which returns process.pid and the worker index) were answered by three different process ids in rotation: {"pid":16865,"instance":"1"}, {"pid":16864,"instance":"0"}, {"pid":16866,"instance":"2"} and so on. Source: Node.js: cluster.
PM2 cluster mode wraps node:cluster in a process manager that also restarts, reloads and monitors processes. It is configured with an ecosystem file, which must be CommonJS (.cjs) in an ESM project. Started with pm2-runtime (PM2’s foreground, container-friendly launcher, so no daemon is left behind), the file below serves the same GET /who from instances 0, 1 and 2, and a SIGINT to pm2-runtime stops all three with exit code 0.
module.exports = {
apps: [
{
name: 'bookshelf-api',
script: 'dist/pm2-main.js',
exec_mode: 'cluster', // PM2's cluster mode, built on node:cluster
instances: 3, // or 'max' for one per CPU
wait_ready: true, // wait for process.send('ready')
listen_timeout: 10000,
kill_timeout: 8000, // time to drain after SIGINT before SIGKILL
max_memory_restart: '400M',
env: { NODE_ENV: 'production', PORT: 4130 },
},
],
};
The entry file is the ordinary bootstrap plus the ready signal that wait_ready waits for:
import { NestFactory } from '@nestjs/core';
import { WhoModule } from './pid.js';
// Under PM2 cluster mode every instance runs this same file; PM2 shares the port.
const app = await NestFactory.create(WhoModule, { logger: ['error'] });
app.enableShutdownHooks();
await app.listen(Number(process.env.PORT ?? 3000));
// Tell PM2 (wait_ready) the instance can take traffic.
process.send?.('ready');
Start it with pm2-runtime start ecosystem.config.cjs in a container, or pm2 start ecosystem.config.cjs on a server with pm2 save and pm2 startup for boot persistence. The memory-restart option is max_memory_restart in the file and --max-memory-restart (two dashes) on the command line. Source: PM2: Cluster mode, PM2: Ecosystem file and PM2: Docker integration.
One process per container. In Kubernetes, ECS or Cloud Run the orchestrator already runs several replicas, restarts crashed ones, scales them on load and rolls them out. Putting a cluster manager inside each container duplicates that and hides the process from the orchestrator’s CPU and memory accounting: one container with four workers and a one-CPU limit gets no more throughput than one worker. The usual advice is therefore one Node.js process per container, sized by a CPU request and limit, scaled horizontally by the platform (see Resource Management, Autoscaling and Docker: Resources, Logging and Monitoring), with graceful shutdown wired to SIGTERM (see Lifecycle Events and Shutdown). The official deployment page makes the same split between scaling up one machine and scaling out; see Deployment for Dockerfiles, PM2 behind NGINX and Kubernetes probes.
| Option | Good for | Cost |
|---|---|---|
|
A single VM or bare server, no process manager |
You write the restart, reload and shutdown logic |
PM2 cluster mode |
A VM or bare server: reloads without downtime, restarts, log handling |
One more moving part, and a second supervisor if it runs inside a container |
One process per container |
Kubernetes, ECS, Cloud Run, anything with replicas and health checks |
Needs an orchestrator; more, smaller instances |
Whichever you choose, processes do not share memory. An in-memory cache, an in-memory rate-limit counter, a WebSocket room list or a session store held in a variable is per process, and with N workers it is N independent copies. Move that state to Redis or the database (see Rate Limiting and Encryption for a shared throttler store, Caching for the Redis cache store and WebSockets for adapters across instances). When a container memory limit and the default V8 heap limit disagree, NODE_OPTIONS=--max-old-space-size=<MB> sets the heap size explicitly (see the Node.js command-line options).
Build speed: tsc, SWC and Rspack
Build and start-up time is a performance topic too: it sets the length of the inner development loop and of every CI run. The Nest CLI compiles with tsc by default, can use the Rust-based SWC compiler (--builder swc, needing @swc/cli and @swc/core), and uses the Rspack bundler by default in monorepo mode (--builder rspack, needing @rspack/core, webpack-node-externals and tsconfig-paths-webpack-plugin). The official SWC recipe states SWC is about 20 times faster than the default TypeScript compiler (the CLI overview says about 10 times). The builder can be chosen per command or in nest-cli.json:
nest start -b swc -w # watch mode with SWC
nest build --builder swc # one-off build
nest build --builder rspack # bundle with Rspack
{
"compilerOptions": {
"builder": "swc",
"typeCheck": true
}
}
SWC strips types and does not check them. --type-check (or "typeCheck": true) runs tsc in noEmit mode alongside, and the Nest 12 CLI also has --no-type-check and build --parallel. To measure the effect, a workspace with 40 generated feature modules (a controller, a service and a DTO each, 162 TypeScript files) was built with nest build six times per builder on the same machine:
| Command | Time | Output |
|---|---|---|
|
0.80 s |
one |
|
0.29 s |
one |
|
0.75 s |
same, with |
|
0.26 s |
a single bundled |
SWC is about 2.7 times faster than tsc when it does not type-check, and about the same as tsc when it does, because the type check is then the bottleneck. A small project makes every number small; the point is the relative gain, which grows with the project. Run the type check in CI (tsc --noEmit or --type-check) so that the fast loop does not let type errors through. SWC does not handle circular imports well, so with TypeORM relations use the Relation<> wrapper type, as in the entities above. The build tool changes the build, not the request path: the same application started in the same time whichever builder produced it (next section).
Two attempts did not pan out and are reported as such. Bundling all dependencies into the Rspack output with a custom rspack.config.js (keeping only Node built-ins external, selected with --rspackPath) compiled in about 100 ms, but the resulting main.js failed at start-up in an Express dependency, so no start-up number is given for a fully bundled Nest application. And a bundled application must pass its HTTP adapter explicitly (new ExpressAdapter()): by default Nest loads @nestjs/platform-express by name at run time, and without it the bundle fails at start-up with No driver (HTTP) has been selected. The official serverless page documents webpack bundling for lower start-up times; measure it on your own application.
Source: NestJS: SWC, NestJS CLI: Overview, NestJS CLI: Usage, SWC and Rspack.
Cold starts, standalone applications and lazy-loading modules
A long-running service pays its start-up cost once. A serverless function, a short-lived job or an autoscaled container that starts on demand pays it on every cold start, so what happens between process start and the first handled event matters. The official serverless chapter’s advice reduces to three things: compile (and optionally bundle) to cut load time, avoid doing slow asynchronous work in providers that every invocation does not need, and load heavy modules lazily. The first two were measured on the 40-module application, 15 timed starts after two warm-ups, wall time of node dist/<entry>.js (which includes starting Node.js itself):
| Entry point | Process time | Notes |
|---|---|---|
Plain Express, one route |
47 ms |
the floor for an Express server |
|
113 ms |
DI container only: what a function or worker should use |
|
167 ms |
adds the HTTP adapter and route registration |
|
205 ms |
Fastify’s plugin system is awaited here |
Three observations. The standalone context is much cheaper than a full HTTP application and is the right entry point for a function that does not need guards, interceptors and pipes (those do not run in a standalone context; use NestFactory.create() when you need them). The platform that is faster per request was not faster to start in this measurement. And the build tool made no difference to start-up: the same Express application started in 167 ms from tsc output, 166 ms from SWC output and 159 ms from the Rspack bundle that keeps node_modules external, because most of the time goes to loading the @nestjs/* packages and building the container, not to the application’s own files. Bootstrap time printed from inside the process is misleading here: with the implicit adapter, NestFactory.create() loads @nestjs/platform-express inside the timed region (59 ms), with a static import of the adapter the same program reported 28 ms for the same total process time. Compare process time.
Lazy-loading modules. LazyModuleLoader (from @nestjs/core) loads and instantiates a module the first time it is needed, and caches it. Combined with a dynamic import() it also defers loading the module’s code and its heavy dependencies, which is where the time goes. The example uses mongoose as a stand-in for any heavy dependency (a PDF library, a spreadsheet writer, a cloud SDK):
// export.module.ts
import { Injectable, Module } from '@nestjs/common';
import { Types } from 'mongoose'; // a stand-in for any heavy dependency
@Injectable()
export class ExportService {
newId(): string {
return new Types.ObjectId().toHexString();
}
}
@Module({ providers: [ExportService], exports: [ExportService] })
export class ExportModule {}
// lazy.ts
import { Controller, Get, Injectable, Module } from '@nestjs/common';
import { LazyModuleLoader } from '@nestjs/core';
@Injectable()
export class ReportsService {
constructor(private readonly lazyModuleLoader: LazyModuleLoader) {}
async exportId(): Promise<string> {
// The dependency is only imported (and the module only instantiated) the first time it is needed.
const { ExportModule, ExportService } = await import('./export.module.js');
const moduleRef = await this.lazyModuleLoader.load(() => ExportModule, { logger: false });
return moduleRef.get(ExportService).newId();
}
}
@Controller('reports')
export class ReportsController {
constructor(private readonly reports: ReportsService) {}
@Get('export')
async export() {
return { id: await this.reports.exportId() };
}
}
@Module({ controllers: [ReportsController], providers: [ReportsService] })
export class LazyReportsModule {}
A spec called GET /reports/export twice and got two different 24-character ids. Then three standalone entry points were timed end to end: one importing ExportModule statically, one loading it lazily for an invocation that needs it, and one lazy entry for an invocation that does not:
| Variant | Process time | In-process detail |
|---|---|---|
Static import, export needed |
148 ms |
the dependency is loaded at start-up whether used or not |
Lazy, export needed |
147 ms |
first |
Lazy, export not needed |
96 ms |
the dependency is never loaded |
The saving is real only for invocations that skip the heavy module: 148 ms down to 96 ms here, and the first call that does use it pays the loading cost once. The limits, from the official page: controllers, resolvers, gateways and middleware cannot be lazy-loaded (routes must exist when the server starts), lazy modules cannot be global and their lifecycle hooks are not called, so lazy loading suits service-style modules in functions, workers and webhooks. Details and the ModuleRef side are on ModuleRef, Circular Dependencies and Lazy Loading.
Two more cold-start rules from the serverless chapter, neither measured here: an asynchronous provider that opens a database connection delays every cold start by the time to connect, so open such connections lazily when only some invocations need them, and cache the bootstrapped application across warm invocations in module scope (the handler example in the lazy-loading page does that). The AWS Lambda wiring with @codegenie/serverless-express, the NODE_OPTIONS=--experimental-require-module setting that CommonJS Nest 12 functions need there, and the container and platform alternatives are on Deployment.
A short checklist
-
Measure before changing anything: event-loop delay, a CPU profile, and a load test with a fixed rate and percentiles.
-
Keep providers singleton; use
AsyncLocalStoragefor per-request data; keep request-scoped providers small and off the hottest routes. -
Switch to Fastify when the HTTP layer is a measured bottleneck, not by default.
-
Cache what is expensive and read-mostly, send
Cache-Controlfor what clients can reuse, and remember that in-process caches are per process. -
Count queries in tests, check plans for the columns you filter and sort on, and paginate every list.
-
Compress at the proxy; stream large downloads and exports; log fewer lines on the hot path (levels, structured logging).
-
One process per container under an orchestrator; PM2 or
node:clusteron a plain server; no shared state in process memory. -
Use SWC or Rspack for the build loop and keep a type check in CI.
-
For functions and short-lived jobs: standalone context, lazy modules, no unnecessary async providers, bootstrap once per warm instance.
What changed recently, and where the book is outdated
-
Request-scope cost is documented, and AsyncLocalStorage is the official alternative. The injection-scopes page states the roughly 5% figure and points to the async-local-storage recipe, so avoid the older habit of making a provider request-scoped just to read the current user.
-
Nest 12 builds. The CLI can use
tsc, SWC or Rspack with--builder; Rspack is the default compiler in monorepo mode and the webpack options (--webpack,--webpackPath) are deprecated in favour of--builder rspackand--rspackPath.--no-type-checkandbuild --parallelare new in the 12 CLI. -
ESM-only packages. The Nest 12 packages are ESM-only, which is why the examples use
.jsimport extensions and top-levelawait; a CommonJS function on AWS Lambda needsNODE_OPTIONS=--experimental-require-module. -
Fastify 5 and Express 5 are the supported platforms; the comparison above was measured on Express 5.2 and Fastify 5.12.
-
Observability.
@nestjs/observe(pre-1.0) is the official, auto-instrumented APM for Nest: request monitoring, traces and runtime profiling (CPU, memory, event-loop delay and garbage collection) sampled from inside the process. It is a hosted service that needs an API key, so it is not measured on this page; it gives request-level data that a one-off profile cannot (see Health Checks and Observability). -
The book’s performance chapter reaches for PM2 cluster mode, Clinic.js, Artillery and Pino. Those tools still exist, but the defaults moved: one process per container under an orchestrator,
--cpu-profand--profbefore reaching for extra tooling (Clinic.js 13 produces no report on Node.js 24.16), SWC or Rspack for builds, and@nestjs/cache-managerwith Keyv stores (see Caching) and structured logging (see Logging, which covers Pino) instead of the older cache and logger setups. The book spells the PM2 memory-restart flag with one dash; the correct option is--max-memory-restart(max_memory_restartin an ecosystem file). -
What this page does not measure. Clinic.js produces no output on Node.js 24.16 (see Profiling), the k6 script is only syntax-checked (Load testing), a fully bundled Rspack build does not start (Build speed: tsc, SWC and Rspack), and nothing is measured on a real serverless platform or against PostgreSQL.
References
-
NestJS: Performance (Fastify): https://docs.nestjs.com/http/performance
-
NestJS: Injection scopes: https://docs.nestjs.com/fundamentals/injection-scopes
-
NestJS: Async local storage: https://docs.nestjs.com/recipes/async-local-storage
-
NestJS: Caching: https://docs.nestjs.com/data/caching
-
NestJS: Compression: https://docs.nestjs.com/http/compression
-
NestJS: File upload and streaming: https://docs.nestjs.com/http/file-upload
-
NestJS: Lifecycle events: https://docs.nestjs.com/fundamentals/lifecycle-events
-
NestJS: Lazy-loading modules: https://docs.nestjs.com/fundamentals/lazy-loading-modules
-
NestJS: Serverless: https://docs.nestjs.com/faq/serverless
-
NestJS: Standalone applications: https://docs.nestjs.com/standalone-applications
-
NestJS: SWC: https://docs.nestjs.com/recipes/swc
-
NestJS CLI: Overview and Usage: https://docs.nestjs.com/cli/overview and https://docs.nestjs.com/cli/usages
-
NestJS: Deployment: https://docs.nestjs.com/deployment
-
NestJS Observe: https://docs.nestjs.com/observability/overview
-
Node.js: Profiling, Debugging,
perf_hooks, cluster and command-line options: https://nodejs.org/en/learn/getting-started/profiling, https://nodejs.org/en/learn/getting-started/debugging, https://nodejs.org/api/perf_hooks.html, https://nodejs.org/api/cluster.html, https://nodejs.org/api/cli.html -
autocannon: https://github.com/mcollina/autocannon
-
Artillery: https://www.artillery.io/docs
-
Clinic.js: https://clinicjs.org/
-
SWC: https://swc.rs/ and Rspack: https://rspack.rs/
-
SQLite: https://www.sqlite.org/eqp.html
-
MDN: HTTP caching: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching