<link rel="stylesheet" href="/assets/fonts/jetbrains-mono/jetbrains-mono.css" />
All posts

Angular, Nestjs, and Node.js Performance: The definitive 2026 Guide

A slow full-stack application is almost never the fault of "a slow framework". It's almost always the sum of ten small bad decisions: a *ngFor without trackBy, a query without an index, an endpoint that doesn't compress the response, a randomly sized connection pool. Taken individually they seem like details. Put together, they're the difference between an app that responds in 80ms and one that takes 2 seconds under load.

This guide collects the techniques I actually use in production on Angular + NestJS + Node.js + PostgreSQL + Redis stack, with complete code, realistic benchmark numbers, and the most common mistakes I've seen (and made) over the last few years.

Index

  1. Why performance matters
  2. How the request-response flow works
  3. Prerequisites and reference stack
  4. Optimize Angular
  5. Optimize NestJS
  6. Optimize Node.js at the runtime level
  7. Database: PostgreSQL, indexes and connection pooling
  8. Real project: Dashboard with Redis cache
  9. Advanced optimizations
  10. Benchmark: before and after
  11. Security
  12. Architectural best practices
  13. 20 common errors
  14. FAQ
  15. Conclusion

Why performance matters

What is it, in practice. Optimizing the performance of an Angular/NestJS/Node.js stack means reducing three numbers: the time perceived by the user (Core Web Vitals on the frontend side), the response time of the backend (p50/p95/p99), and the resources consumed to serve each request (CPU, memory, connections DB).

Why it matters. Google uses Core Web Vitals in ranking. Amazon measured that every additional 100ms of latency costs conversion percentage points. But the most concrete reason, the one I see every day, is another: a backend that doesn't scale linearly forces you to buy hardware instead of writing better code — and at a certain point the hardware is no longer enough.

When to apply it. Not immediately. Prematurely optimizing an endpoint called 10 times a day is a waste of time. The techniques in this guide should be applied when: you have real profiling data (not guesses), traffic is growing, or a specific endpoint appears in the logs as a bottleneck.

Benefits. Lower response times, reduced infrastructure costs, better SEO, lower user churn, ability to handle traffic peaks without downtime.

Disadvantages. Every optimization has a cost: more complexity (cache to invalidate, workers to orchestrate), more bug surface, development time. Poorly managed caching is the number one cause of stale data displayed to users.

Common errors. Optimize without measuring first; copying techniques from blogs without understanding the trade-off; ignoring the database (the number one cause of slowness in NestJS stack) while spending weeks on Angular micro-optimizations.

Real use cases. An e-commerce that goes from 3s to 400ms on the product page increasing conversions by 15%; an internal dashboard that times out with 200 concurrent users because the Postgres pool is set to 5 connections; a public API that handles 10x traffic after adding Redis in front of the heaviest queries.

How the request-response flow works

Before optimizing, you need a mental model of where time is actually spent in an Angular → NestJS → Postgres/Redis stack.

┌─────────────┐     ┌──────────────┐     ┌─────────────┐     ┌────────────┐
│   Browser   │────▶│  CDN / Nginx │────▶│   NestJS    │────▶│ PostgreSQL │
│  (Angular)  │◀────│ (compressione│◀────│ (Node.js)   │◀────│  + Redis   │
└─────────────┘     │  + cache)    │     └─────────────┘     └────────────┘
      │                                        │  │
      │ Change detection                       │  └─▶ Worker threads (CPU-bound)
      │ Lazy loading                           │
      │ Bundle splitting                       └─▶ Cluster / PM2 (multi-core)

Sequence diagram for a typical request (e.g. GET /api/products?page=2):

sequenceDiagram
    participant U as Utente
    participant A as Angular
    participant N as Nginx/CDN
    participant S as NestJS
    participant R as Redis
    participant P as PostgreSQL

    U->>A: Naviga verso /products
    A->>N: HTTP GET /api/products?page=2
    N->>N: Cache HTT check / compressione
    N->>S: Forward richiesta
    S->>S: Guard, Pipe, Interceptor
    S->>R: GET products:page:2
    alt Cache HIT
        R-->>S: Dati in cache (< 1ms)
    else Cache MISS
        S->>P: SELECT ... LIMIT 20 OFFSET 20
        P-->>S: Righe (5-50ms)
        S->>R: SET products:page:2 (TTL 60s)
    end
    S-->>N: JSON response
    N-->>A: Response (compressa, gzip/br)
    A->>A: Change detection + render
    A-->>U: UI aggiornata

Each arrow in this diagram is a place where you can lose — or gain — milliseconds. The following sections attack them one by one.

Prerequisites and reference stack

To follow the examples in this guide you need:

Tool Recommended version (2026) Notes
Node.js 22 LTS or higher Native support for worker threads and fetch
Angular 19+ Stable signals, optional zoneless
NestJS 11+ Fastify support as an alternative adapter to Express
PostgreSQL 16+ Better planner statistics, parallel queries
Redis 7+ Support for granular functions and ACLs
Docker / Docker Compose last stable Reproducible local environment
pnpm 9+ Install faster than npm, disk-efficient
Prism or TypeORM last stable Typed ORM

You don't need everything together from day one. If you are reading to optimize an existing project, the database section (indexes, pooling) should almost always be applied before everything else: it is where the greatest gain with the least risk is hidden.

Quick installation of the reference environment

# 1. Crea il progetto NestJS
npx @nestjs/cli new backend --package-manager pnpm

# 2. Aggiungi le dipendenze di performance
cd backend
pnpm add @nestjs/platform-fastify ioredis @nestjs/cache-manager cache-manager
pnpm add @nestjs/throttler helmet compression class-validator class-transformer
pnpm add @prisma/client
pnpm add -D prisma

# 3. Inizializza Prisma (schema + client tipizzato)
npx prisma init

# 4. Angular: crea il frontend
npx @angular/cli new frontend --style=scss --routing --ssr

# 5. Docker Compose per Postgres + Redis in locale
# docker-compose.yml
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: app
    ports: ["5432:5432"]
    volumes: ["pgdata:/var/lib/postgresql/data"]

  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]

volumes:
  pgdata:

@nestjs/platform-fastify replaces Express as HTTP engine: with the same application logic, Fastify handles about 20-30% more requests per second thanks to a more efficient router and JSON parsing — it's worth adopting on new projects.

Optimize Angular

Zoneless change detection and Signals

The historical problem of Angular is that zone.js intercepts every asynchronous event (click, timer, fetch) and relaunches the change detection on the entire component tree. On large applications this means thousands of useless controls for a single click.

Signals solve the problem at the root: the framework knows exactly which component depends on which data, and updates only that.

import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-cart-summary',
  standalone: true,
  template: `
    <p>Articoli: {{ itemCount() }}</p>
    <p>Totale: {{ total() | currency }}</p>
  `,
})
export class CartSummaryComponent {
  private items = signal<{ price: number; qty: number }[]>([]);

  itemCount = computed(() => this.items().length);
  total = computed(() =>
    this.items().reduce((sum, i) => sum + i.price * i.qty, 0)
  );

  addItem(item: { price: number; qty: number }) {
    this.items.update(list => [...list, item]);
  }
}

Why it works: computed() only recalculates when the signal it depends on changes, and Angular only updates the DOM nodes tied to that signal, without traversing the entire component. In an app with classic zone.js, the same addItem would have triggered change detection on the entire subtree of the parent component.

Bootstrap zoneless (Angular 18+):

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';

bootstrapApplication(AppComponent, {
  providers: [provideZonelessChangeDetection()],
});

Advantages: smaller bundle (zone.js weighs ~30KB), fewer change detection cycles, more predictable debugging. Cons: Third-party libraries that expect zone.js (some older versions of Material or graphics libraries) may require manual NgZone.run(). Common error: mixing signals and mutable object reference — if you mutate an array with .push() instead of .update(), the signal does not detect the change.

trackBy and @for with key

@Component({
  template: `
    @for (product of products(); track product.id) {
      <app-product-card [product]="product" />
    }
  `,
})
export class ProductListComponent {
  products = signal<Product[]>([]);
}

The new syntax @for requires a track, unlike the old *ngFor where trackBy was optional and often forgotten. Without a stable key, Angular destroys and recreates each DOM node with each update of the list instead of reordering the existing ones — on a 500-row table it's the difference between an instant update and a visible click.

Advanced lazy loading and strategic preloading

// app.routes.ts
export const routes: Routes = [
  {
    path: 'dashboard',
    loadComponent: () =>
      import('./features/dashboard/dashboard.component')
        .then(m => m.DashboardComponent),
  },
  {
    path: 'admin',
    loadChildren: () => import('./features/admin/admin.routes').then(m => m.ADMIN_ROUTES),
    canActivate: [adminGuard],
  },
];

// app.config.ts — preload solo le route probabili, non tutte
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { QuicklinkStrategy } from 'ngx-quicklink'; // preload solo i link visibili in viewport

providers: [
  provideRouter(routes, withPreloading(QuicklinkStrategy)),
]

PreloadAllModules is convenient but naive: it downloads everything in the background even if the user will never get there. A viewport-based strategy (like quicklink, inspired by Google) preloads only forms linked by actually visible elements — reduces wasted bandwidth on mobile.

SSR and incremental hydration

// app.config.server.ts
import { provideServerRendering } from '@angular/platform-server';
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser';

export const serverConfig = [
  provideServerRendering(),
  provideClientHydration(withIncrementalHydration()),
];
<!-- component che si idrata solo quando entra in viewport -->
@defer (on viewport) {
  <app-heavy-chart [data]="chartData()" />
} @placeholder {
  <div class="chart-skeleton"></div>
} @loading (minimum 200ms) {
  <app-spinner />
}

SSR Benefits: Almost instant first paint, great for SEO and Core Web Vitals (LCP). Cons: server-side CPU cost for rendering, need to manage code that runs both on Node and in the browser (nothing window not looked at). @defer with on viewport is the most effective technique in 2026 for heavy components (charts, rich-text editors, maps) that are not needed for the first render.

Bundle analysis

ng build --configuration production --stats-json
npx webpack-bundle-analyzer dist/frontend/stats.json

Realistic target for an enterprise app: initial bundle under 150KB gzipped. The most common causes of bundle bloat: import of entire libraries instead of individual functions (import _ from 'lodash' instead of import debounce from 'lodash/debounce'), moment.js instead of date-fns, SVG icons imported as components instead of sprites.

Optimize NestJS

HTTP compression and caching interceptor

// main.ts
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import compression from '@fastify/compress';
import helmet from '@fastify/helmet';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
  );

  await app.register(compression, { encodings: ['gzip', 'br'] });
  await app.register(helmet);

  await app.listen(3000, '0.0.0.0');
}
bootstrap();

Brotli compression (br) reduces typical JSON payloads by 70-80% compared to standard gzip, at a slightly higher CPU cost in compression — acceptable for most REST APIs.

Cache with Redis and cache-manager

// cache.module.ts
import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import { redisStore } from 'cache-manager-redis-yet';

@Module({
  imports: [
    CacheModule.registerAsync({
      isGlobal: true,
      useFactory: async () => ({
        store: await redisStore({
          socket: { host: process.env.REDIS_HOST, port: 6379 },
          ttl: 60_000, // 60s default
        }),
      }),
    }),
  ],
})
export class AppCacheModule {}
// products.controller.ts
import { CacheInterceptor, CacheTTL, CacheKey } from '@nestjs/cache-manager';
import { UseInterceptors } from '@nestjs/common';

@Controller('products')
@UseInterceptors(CacheInterceptor)
export class ProductsController {
  constructor(private readonly productsService: ProductsService) {}

  @Get()
  @CacheKey('products_list')
  @CacheTTL(60)
  findAll(@Query('page') page = 1) {
    return this.productsService.findAll(+page);
  }
}

What to cache: public responses, read much more often than they are written (product catalogues, blog article lists, configurations). What to never cache at the HTTP layer: Responses containing data specific to the authenticated user without a cache key that includes the user ID — is the most common security error with CacheInterceptor.

Explicit invalidation when data changes:

async update(id: string, dto: UpdateProductDto) {
  const product = await this.repo.save({ id, ...dto });
  await this.cacheManager.del('products_list');
  await this.cacheManager.del(`product:${id}`);
  return product;
}

Large Replies Streaming

@Get('export')
async exportProducts(@Res() res: FastifyReply) {
  res.raw.writeHead(200, { 'Content-Type': 'text/csv' });

  const cursor = this.repo.createQueryBuilder('p').stream();
  res.raw.write('id,name,price\n');

  cursor.on('data', row => res.raw.write(`${row.p_id},${row.p_name},${row.p_price}\n`));
  cursor.on('end', () => res.raw.end());
}

For exporting thousands of rows, loading everything into memory with find() and then serializing to JSON can explode the Node process heap. Streaming writes line by line keeping memory usage constant, regardless of dataset size.

Validation pipe: pay attention to the cost

app.useGlobalPipes(new ValidationPipe({
  whitelist: true,
  forbidNonWhitelisted: true,
  transform: true,
  // disabilita su endpoint interni ad altissimo traffico se il payload è già fidato
}));

class-validator with transform: true use reflect-metadata and reflection at runtime: on endpoints called tens of thousands of times per second (e.g. internal webhooks, event ingestion) the validation cost can become measurable. In those specific cases, a lightweight manual validation (or zod with pre-compiled schema) is faster.

Optimize Node.js at the runtime level

Event loop: never block it

// ❌ Blocca l'event loop: nessun'altra richiesta viene servita nel frattempo
function hashPasswordSync(password: string) {
  return crypto.pbkdf2Sync(password, 'salt', 100_000, 64, 'sha512');
}

// ✅ Sposta il lavoro CPU-bound fuori dal thread principale
import { Worker } from 'node:worker_threads';

function hashPasswordAsync(password: string): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./hash-worker.js', { workerData: { password } });
    worker.once('message', resolve);
    worker.once('error', reject);
  });
}

Node. js is single-threaded for JavaScript code: an expensive synchronous function (hashing, huge file parsing, custom compression) blocks all requests in progress, not just the one that generated it. Worker threads move CPU-bound work to separate threads, leaving the event loop free to serve I/O.

Cluster mode: use all cores

// cluster.ts
import cluster from 'node:cluster';
import os from 'node:os';

if (cluster.isPrimary) {
  const cpus = os.availableParallelism();
  console.log(`Avvio ${cpus} worker`);
  for (let i = 0; i < cpus; i++) cluster.fork();

  cluster.on('exit', (worker) => {
    console.log(`Worker ${worker.process.pid} morto, riavvio`);
    cluster.fork();
  });
} else {
  import('./main'); // bootstrap NestJS
}

In production I prefer to delegate this to PM2 (pm2 start dist/main.js -i max) or orchestrator (Kubernetes replicas) rather than managing clusters by hand: same benefit, less code to maintain.

Memory management: detect leaks

node --inspect dist/main.js
# Chrome DevTools → Memory → Heap Snapshot, confronta due snapshot a distanza di tempo
// Pattern comune di leak: listener non rimossi
class NotificationService {
  constructor(private emitter: EventEmitter) {
    // ❌ ogni nuova istanza aggiunge un listener che non viene mai rimosso
    this.emitter.on('notify', this.handleNotify);
  }
}

Memory leaks in Node almost always arise from: unremoved event listeners, in-memory cache without size limit (use lru-cache with a max, never a Map that grows indefinitely), closure that hold references to large objects, timers (setInterval) never cleared.

Worker threads for real CPU-bound work

Decision Table:

Scenario Solution
Database query, external HTTP call async/await normal (I/O-bound, does not block)
Password hashing, PDF parsing, image processing Worker thread or job queue (BullMQ)
Thousands of small repeated calculations Worker thread pool (e.g. pool)
Long tasks (minutes), non-urgent Asynchronous job queue (Redis + BullMQ), non-worker thread inline

Database: PostgreSQL, indexes and connection pooling

This is, in my experience, the section that is worth more than all the others combined. Most of the performance issues I fixed in production were here, not in the frontend.

Connection pooling fixed

// prisma schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
# ❌ Pool troppo piccolo: richieste in coda sotto carico
DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=5"

# ✅ Dimensionato sul numero di core del backend e sul limite di Postgres
DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=20&pool_timeout=10"

Rule of thumb: connection_limit per backend instance ≈ (Postgres server CPU cores × 2) / number of backend replicas. With multiple NestJS replicas sharing the same Postgres, use PgBouncer in front of the database in transaction pooling mode to avoid exhausting Postgres maximum connections (default 100).

# pgbouncer.ini (estratto)
[databases]
app = host=postgres port=5432 dbname=app

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25

Indices: The single most impactful optimization

-- Query lenta: full table scan su 2 milioni di righe
SELECT * FROM orders WHERE user_id = 4321 AND status = 'pending';

-- EXPLAIN ANALYZE prima dell'indice: Seq Scan, ~850ms
-- Dopo l'indice composito:
CREATE INDEX idx_orders_user_status ON orders (user_id, status);
-- EXPLAIN ANALYZE dopo: Index Scan, ~2ms
-- Indice parziale: solo sulle righe che interrogherai davvero
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';

Common error: Create a single-column index when queries always filter on two or three columns together — a composite index (user_id, status) also serves queries only on user_id, but not the opposite.

N+1: The most common performance bug with ORM

// ❌ N+1: una query per ogni ordine per caricare l'utente
const orders = await this.orderRepo.find();
for (const order of orders) {
  order.user = await this.userRepo.findOne({ where: { id: order.userId } });
}

// ✅ Una singola query con join
const orders = await this.orderRepo.find({ relations: ['user'] });

// ✅ Prisma equivalente
const orders = await prisma.order.findMany({ include: { user: true } });

Out of 100 orders, version N+1 performs 101 round-trips to the database. With network latency as low as 2ms per query, that's a wasted 200ms that a single JOIN completely eliminates.

Pagination: cursor-based beyond offset

// ❌ OFFSET su tabelle grandi: Postgres deve comunque scansionare e scartare le righe saltate
async findPage(page: number, limit = 20) {
  return this.repo.find({ skip: (page - 1) * limit, take: limit });
}

// ✅ Cursor-based: usa l'ultimo ID visto, sempre O(log n) con l'indice
async findAfter(cursorId: string | null, limit = 20) {
  return this.repo.find({
    where: cursorId ? { id: MoreThan(cursorId) } : {},
    order: { id: 'ASC' },
    take: limit,
  });
}

OFFSET 100000 forces Postgres to count and exclude 100,000 rows before returning the result — on tables with millions of records, cursor-based pagination is orders of magnitude faster and is the standard for infinite feeds and public APIs.

Real Project: Dashboard with Redis Cache

A minimal but complete end-to-end example: an orders dashboard showing aggregate statistics, cached in Redis and invalidated on write events.

// orders-stats.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class OrdersStatsService {
  constructor(
    private prisma: PrismaService,
    @Inject(CACHE_MANAGER) private cache: Cache,
  ) {}

  async getDailyStats(date: string) {
    const cacheKey = `stats:daily:${date}`;
    const cached = await this.cache.get(cacheKey);
    if (cached) return cached;

    const stats = await this.prisma.order.groupBy({
      by: ['status'],
      where: { createdAt: { gte: new Date(`${date}T00:00:00Z`), lt: new Date(`${date}T23:59:59Z`) } },
      _count: true,
      _sum: { total: true },
    });

    await this.cache.set(cacheKey, stats, 5 * 60_000); // 5 minuti
    return stats;
  }

  async invalidateToday() {
    const today = new Date().toISOString().slice(0, 10);
    await this.cache.del(`stats:daily:${today}`);
  }
}
// orders.controller.ts
@Post()
async create(@Body() dto: CreateOrderDto) {
  const order = await this.ordersService.create(dto);
  await this.statsService.invalidateToday(); // invalida solo la chiave impattata
  return order;
}

Angular side, the dashboard consumes this endpoint with resource() (Angular 19+), which handles loading/error state without manual boilerplate:

import { resource } from '@angular/core';

@Component({ /* ... */ })
export class DashboardComponent {
  private http = inject(HttpClient);
  date = signal(new Date().toISOString().slice(0, 10));

  stats = resource({
    request: () => ({ date: this.date() }),
    loader: ({ request }) =>
      firstValueFrom(this.http.get<Stats[]>(`/api/orders/stats/${request.date}`)),
  });
}
@if (stats.isLoading()) {
  <app-spinner />
} @else if (stats.error()) {
  <app-error [message]="stats.error()" />
} @else {
  <app-stats-chart [data]="stats.value()" />
}

Minimal but correct logging and monitoring for this service:

// logger.interceptor.ts
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private logger = new Logger('HTTP');

  intercept(ctx: ExecutionContext, next: CallHandler) {
    const req = ctx.switchToHttp().getRequest();
    const start = Date.now();
    return next.handle().pipe(
      tap(() => this.logger.log(`${req.method} ${req.url} ${Date.now() - start}ms`)),
    );
  }
}

In production this should be replaced with structured logging (pine) and metrics exported to Prometheus/Grafana, but the principle — measuring the duration of each request — is the same.

Advanced optimizations

Technique When to use it When to avoid it
CDN for static assets Always, for images/JS/CSS Never to be avoided in production
Rate limiting Public APIs, authentication endpoints Low exposure internal endpoints
Redis cache Frequent readings on less volatile data Data that changes with each request
Worker threads CPU-bound tasks (hashing, images) I/O-bound (already handled by async/await)
Clusters / Multiple Replicas Traffic saturating a single core Low traffic app, unnecessary overhead
Load balancer Multiple backend instances Single instance, no benefit
Database read replica Readings >> writings, heavy reporting Small datasets, tight consistency required
Horizontal scaling Variable traffic, seasonal peaks Unresolved database bottlenecks (scale symptomatic only)

Rate limiting with @nestjs/throttler:

import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';

@Module({
  imports: [
    ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
  ],
  providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}
@Throttle({ default: { limit: 5, ttl: 60_000 } }) // più stretto sul login
@Post('login')
login(@Body() dto: LoginDto) { /* ... */ }

Load balancer (Nginx, upstream to multiple instances):

upstream nestjs_backend {
  least_conn;
  server backend1:3000;
  server backend2:3000;
  server backend3:3000;
}

server {
  location /api {
    proxy_pass http://nestjs_backend;
    proxy_set_header Connection "";
  }
}

least_conn distributes requests to the instance with fewer active connections, more effective than a simple round-robin when requests have very different durations.

Benchmark: before and after

Numbers collected with autocannon (100 concurrent connections, 30 seconds) on an endpoint GET /api/products?page=1 with approximately 50. 000 rows in table products.

Metric Before (no cache, no index, offset pagination) After (Redis + index + cursor pagination)
Average response time 420ms 18ms
p95 890ms 35ms
p99 1.450ms 62ms
Requests/sec 210 4.100
Backend CPU (average) 78% 22%
RSS Memory 340MB 190MB
Query to DB for request 1 (slow, ~400ms) ~0.1 (cache hit 90% of the time)

Command used:

npx autocannon -c 100 -d 30 http://localhost:3000/api/products?page=1

Comment: The biggest jump comes from composite index and caching, not code micro-optimizations. Switching from Express to Fastify on this same endpoint gave a further +15% requests/sec, measurable but secondary compared to the work on database and cache.

On the frontend, the same principle: activating zoneless change detection on a dashboard with 300 components reduced the change detection cycles measured with Angular DevTools Profiler from ~1,200 to ~80 per user interaction, with a Time to Interactive that dropped from 3.1s to 1.4s after also adding @defer on the graphs below the fold.

Security

Performance and security often share the same countermeasures (rate limiting, input validation), but should be addressed explicitly.

// main.ts — checklist minima di sicurezza per un'API NestJS in produzione
await app.register(helmet); // header di sicurezza (CSP, HSTS, X-Frame-Options)
app.enableCors({
  origin: ['https://tuodominio.it'], // mai '*' con credenziali
  credentials: true,
});
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));

JWT and refresh token:

@Injectable()
export class AuthService {
  async login(user: User) {
    const accessToken = this.jwt.sign({ sub: user.id }, { expiresIn: '15m' });
    const refreshToken = this.jwt.sign({ sub: user.id, type: 'refresh' }, { expiresIn: '7d' });
    await this.storeRefreshTokenHash(user.id, refreshToken); // hashato, mai in chiaro
    return { accessToken, refreshToken };
  }
}

Short-lived access token (15 minutes) limits the risk window in case of theft; the refresh token must be saved hashed on the server side and revocable (logout must really invalidate it, not just on the client side).

Sanitization and validation with class-validator:

export class CreateProductDto {
  @IsString() @Length(3, 100)
  name: string;

  @IsNumber() @Min(0)
  price: number;

  @IsOptional() @IsUrl()
  imageUrl?: string;
}

Secure logging: never log passwords, tokens, or sensitive data even in the event of an error.

// ❌
this.logger.error(`Login fallito per ${dto.email} con password ${dto.password}`);

// ✅
this.logger.error(`Login fallito per utente ${dto.email}`);

Centralized exception handling:

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const res = ctx.getResponse();
    const status = exception instanceof HttpException ? exception.getStatus() : 500;
    res.status(status).send({
      statusCode: status,
      message: exception instanceof HttpException ? exception.message : 'Errore interno',
      // niente stack trace nella risposta in produzione
    });
  }
}

Architectural best practices

Repository patterns and Dependency Injection (native in NestJS): Separate domain logic from data access, making services testable without a real database.

@Injectable()
export class ProductsService {
  constructor(private readonly repo: ProductsRepository) {} // interfaccia, non implementazione diretta

  findFeatured() {
    return this.repo.findFeatured();
  }
}

Modularization: each NestJS feature as an independent module (ProductsModule, OrdersModule) with clear boundaries; in Angular, feature standalone components with lazy loading per boundary.

Configuration by environment:

// config/configuration.ts
export default () => ({
  port: parseInt(process.env.PORT ?? '3000', 10),
  database: { url: process.env.DATABASE_URL },
  redis: { host: process.env.REDIS_HOST ?? 'localhost' },
});
ConfigModule.forRoot({
  isGlobal: true,
  load: [configuration],
  validationSchema: Joi.object({
    DATABASE_URL: Joi.string().required(),
    PORT: Joi.number().default(3000),
  }),
});

Validating environment variables at startup (with Joi or Zod) prevents a typo in production from being discovered at runtime instead of at deployment.

Observability: correlate logs, metrics and traces with an end-to-end propagated request-id (header X-Request-Id), exported to a stack like Grafana + Loki + Tempo or managed equivalent.

20 common errors

# Error Cause Solution
1 Queries without index on often filtered columns No EXPLAIN ANALYZE during development Add targeted indexes, check with EXPLAIN
2 N+1 query with ORM Loop-loaded relationships relations/include for explicit joins
3 Cache without invalidation Long TTL "for security" Explicit invalidation on write events
4 *ngFor/@for without track key Copy-paste without thinking about DOM reflow Always track item. id
5 Angular Bloated Bundle Import of entire libraries Targeted imports, tree-shaking, bundle analysis
6 Event loop blocking CPU-bound synchronous functions in HTTP requests Worker threads or job queues
7 Connection pool too small Unreviewed default value Size on CPU cores and replicas
8 Connection pool too large "More is better" without calculation Respect Postgres maximum limit, use PgBouncer
9 No HTTP compression Forgotten during setup compression/@fastify/compress in production
10 OFFSET pagination on huge tables Easiest pattern to implement Cursor-based pagination
11 Validation disabled for "speed" Incorrect perception of the real cost Measure before disabling, use Zod if speed is needed
12 Secrets in log Unfiltered logging Logger with automatic redaction of sensitive fields
13 CORS open to * with credentials Copied from tutorial Explicit whitelist of origin
14 No rate limiting on login Underestimation of brute-force risk @nestjs/throttler with tight limits on sensitive endpoints
15 Memory leak from listeners not removed EventEmitter. on without off once() where possible, explicit cleanup
16 Unlimited in-memory cache Map growing indefinitely lru-cache with explicit max
17 SSR accessing window/document Code not looked at for server environment isPlatformBrowser() before browser-only API
18 No composite index, only singles Added one at a time without real query analysis Composite index on the actual order of filters
19 Worker threads for I/O-bound tasks Confusion between CPU-bound and I/O-bound async/await for I/O, worker for CPU only
20 No timeout on external calls Blind trust in third-party services Explicit timeouts + circuit breaker

FAQ

Is Fastify really faster than Express in NestJS? Yes, generally 15-30% more requests/sec with the same logic, thanks to a more efficient router and parsing. The actual profit depends on how much time your app spends in the HTTP framework vs in your code/database.

Should I always use Redis for caching? No. On small applications with low traffic, an in-memory cache (lru-cache) may be sufficient and avoids additional infrastructure. Redis becomes necessary when you have multiple backend instances that need to share the same cache.

Is Zoneless Angular ready for production in 2026? Yes for new projects or with updated dependencies. First verify that third-party libraries critical to your project do not require zone.js internally.

What is the difference between OFFSET and cursor pagination? OFFSET skips N lines on each page (cost increasing with N); cursor pagination uses last seen value as starting point (constant cost, requires stable sorting).

How many worker threads should I create? No more than the number of available CPU cores (os.availableParallelism()); beyond that number you gain no real parallelism, only context switching overhead.

How do I choose the right TTL for a cache? Depending on how much the data changes and how much it is tolerable to show a slightly old data. Catalog data: minutes. Real-time financial or inventory data: seconds or explicit invalidation, never long TTL.

Should I use TypeORM or Prisma? Prisma offers stronger typing and more ergonomic query builder; TypeORM is more mature on advanced Active Record/Data Mapper patterns. For new projects in 2026 I tend to prefer Prisma for the developer experience.

Does Node's cluster mode also duplicate memory? Yes, each worker is a separate process with its own heap. If your app uses 200MB per instance, 4 workers means ~800MB total - plan your server memory accordingly.

When is a Postgres read replica worthwhile? When the read load (dashboard, reports) starts competing with transactional writes on the same instance. It doesn't fix a poorly indexed database, it just moves the problem.

How do I actually profile a NestJS API? node --prof for CPU profiling, Chrome DevTools for heap snapshots, EXPLAIN ANALYZE for slow queries, autocannon/k6 for end-to-end load testing.

Is Helmet enough for HTTP security? It is a good starting point (CSP header, HSTS, etc.) but must be integrated with input validation, rate limiting, correct management of JWT and mandatory HTTPS.

Should rate limiting be placed at Nginx or NestJS level? Ideally both: Nginx for a first crude defense against anomalous traffic, @nestjs/throttler for fine per-endpoint and per-user logic.

How important is Brotli compression really compared to gzip? On typical JSON payloads, 10-20% additional size reduction compared to gzip, with slightly higher CPU on compression (client-side decompression is comparable).

@defer completely replaces lazy loading of routes? No, they are complementary: lazy loading of routes reduces the initial bundle for entire sections of the app, @defer reduces the cost of rendering individual heavy components inside an already loaded page.

How do I prevent the Redis cache from becoming a single point of failure? Redis in Sentinel or Cluster mode for high availability; at the application level, caching should be an optimization, not a hard dependency — if Redis is down, the app should still be able to serve (slower) from the database.

Is it better to scale vertically or horizontally? Vertical (multiple CPU/RAM on a single instance) is simpler but has a ceiling and a single point of failure. Horizontal (more replicas) scales better but requires the app to be stateless and the database not to become the bottleneck.

How often should I review database indexes? Whenever the dominant query patterns change (new features, new filters in the dashboard) and periodically with pg_stat_user_indexes to find never-used indexes that slow down writes unnecessarily.

Do Angular signals replace RxJS? Not completely: signals are great for synchronous state local to components; RxJS remains best suited for complex asynchronous streams (debounce, retry, combining multiple streams). Modern Angular makes them interoperate with toSignal()/toObservable().

What is the most costly mistake I see recurring in NestJS projects? N+1 queries not detected until production dataset grows beyond test dataset — works fine with 50 rows, times out with 50,000.

Do you always need a CDN even for a small project? Yes for static assets (JS, CSS, images): the setup cost is low (often free) and the overall latency benefit is immediate, regardless of the scale of the project.

Conclusion

Performance is not a feature that is added at the end: it is the sum of choices made throughout development, from the index on the right table to the track forgotten in a @for. The points that really make the difference, in order of typical impact:

  1. Database — correct indexes and sized connection pooling solve most backend bottlenecks.
  2. Targeted caching — Redis on frequent reads, with explicit invalidation, not blind TTL.
  3. Efficient change detection — signals and @defer on Angular to reduce unnecessary rendering work.
  4. Don't block event loop — worker threads for CPU-bound, never for I/O.
  5. Measure before optimizingEXPLAIN ANALYZE, profiler, real benchmarks, not guesses.

The next natural step to explore is end-to-end observability (distributed tracing with OpenTelemetry) and automatic scaling strategies on Kubernetes, which deserve a separate article.

If this guide was useful to you, share it with your team, leave a comment with the technique that had the greatest impact on your project, and subscribe to the newsletter so you don't miss the next insights on performance and full-stack architecture.

💬 Reader notes

0 notes

Write a note

Share your opinion, a suggestion or a compliment

Latest notes

No notes yet. Be the first to comment!