Skip to content
NestJS best practices 5 min read

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-patternFaster approach
Query inside a for loop (N+1)Single IN (...) query or DataLoader
SELECT *Project only required columns
Returning all rowsCursor/offset pagination
await calls in seriesPromise.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.REQUEST for genuine per-request state and prefer AsyncLocalStorage for 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 StreamableFile instead 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.all rather 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.
Last updated June 14, 2026
Was this helpful?