Un'applicazione full-stack lenta non è quasi mai colpa di "un framework lento". È quasi sempre la somma di dieci piccole decisioni sbagliate: un *ngFor senza trackBy, una query senza indice, un endpoint che non comprime la risposta, un pool di connessioni dimensionato a caso. Prese singolarmente sembrano dettagli. Messe insieme, sono la differenza tra un'app che risponde in 80ms e una che ne impiega 2 secondi sotto carico.
Questa guida raccoglie le tecniche che uso davvero in produzione su stack Angular + NestJS + Node.js + PostgreSQL + Redis, con codice completo, numeri di benchmark realistici e gli errori più comuni che ho visto (e commesso) negli ultimi anni.
Indice
- Perché le performance contano
- Come funziona il flusso richiesta-risposta
- Prerequisiti e stack di riferimento
- Ottimizzare Angular
- Ottimizzare NestJS
- Ottimizzare Node.js a livello di runtime
- Database: PostgreSQL, indici e connection pooling
- Progetto reale: dashboard con cache Redis
- Ottimizzazioni avanzate
- Benchmark: prima e dopo
- Sicurezza
- Best practice architetturali
- 20 errori comuni
- FAQ
- Conclusione
Perché le performance contano
Cos'è, in pratica. Ottimizzare le performance di uno stack Angular/NestJS/Node.js significa ridurre tre numeri: il tempo percepito dall'utente (Core Web Vitals lato frontend), il tempo di risposta del backend (p50/p95/p99), e le risorse consumate per servire ogni richiesta (CPU, memoria, connessioni DB).
Perché è importante. Google usa i Core Web Vitals nel ranking. Amazon ha misurato che ogni 100ms di latenza in più costa punti percentuali di conversione. Ma il motivo più concreto, quello che vedo tutti i giorni, è un altro: un backend che non scala linearmente ti costringe a comprare hardware invece di scrivere codice migliore — e a un certo punto l'hardware non basta più.
Quando applicarlo. Non subito. Ottimizzare prematuramente un endpoint chiamato 10 volte al giorno è tempo sprecato. Le tecniche di questa guida vanno applicate quando: hai dati di profiling reali (non supposizioni), il traffico sta crescendo, o un endpoint specifico appare nei log come collo di bottiglia.
Benefici. Tempi di risposta più bassi, costi infrastrutturali ridotti, migliore SEO, minore churn utenti, capacità di gestire picchi di traffico senza downtime.
Svantaggi. Ogni ottimizzazione ha un costo: più complessità (cache da invalidare, worker da orchestrare), più superficie di bug, tempo di sviluppo. Il caching mal gestito è la prima causa di dati stantii mostrati agli utenti.
Errori comuni. Ottimizzare senza misurare prima; copiare tecniche da blog senza capire il trade-off; ignorare il database (la causa numero uno di lentezza in stack NestJS) mentre si passano settimane su micro-ottimizzazioni Angular.
Casi d'uso reali. Un e-commerce che passa da 3s a 400ms sulla pagina prodotto aumentando le conversioni del 15%; una dashboard interna che con 200 utenti concorrenti va in timeout perché il pool Postgres è settato a 5 connessioni; un'API pubblica che regge un traffico 10x dopo aver aggiunto Redis davanti alle query più pesanti.
Come funziona il flusso richiesta-risposta
Prima di ottimizzare, serve un modello mentale di dove il tempo viene effettivamente speso in uno stack Angular → NestJS → Postgres/Redis.
┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌────────────┐
│ 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)
Diagramma di sequenza per una richiesta tipica (es. 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
Ogni freccia di questo diagramma è un punto dove puoi perdere — o guadagnare — millisecondi. Le sezioni seguenti li attaccano uno per uno.
Prerequisiti e stack di riferimento
Per seguire gli esempi di questa guida ti servono:
| Strumento | Versione consigliata (2026) | Note |
|---|---|---|
| Node.js | 22 LTS o superiore | Supporto nativo a worker threads e fetch |
| Angular | 19+ | Signals stabili, zoneless opzionale |
| NestJS | 11+ | Supporto Fastify come adapter alternativo a Express |
| PostgreSQL | 16+ | Migliori statistiche del planner, parallel query |
| Redis | 7+ | Supporto a funzioni e ACL granulari |
| Docker / Docker Compose | ultima stabile | Ambiente locale riproducibile |
| pnpm | 9+ | Install più veloce di npm, disk-efficient |
| Prisma o TypeORM | ultima stabile | ORM tipizzato |
Non ti serve tutto insieme dal primo giorno. Se stai leggendo per ottimizzare un progetto esistente, la sezione database (indici, pooling) va quasi sempre applicata prima di tutto il resto: è dove si nasconde il guadagno più grande con il minor rischio.
Installazione rapida dell'ambiente di riferimento
# 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 sostituisce Express come motore HTTP: a parità di logica applicativa, Fastify regge circa il 20-30% di richieste al secondo in più grazie a un router e a un parsing JSON più efficienti — vale la pena adottarlo su progetti nuovi.
Ottimizzare Angular
Zoneless change detection e Signals
Il problema storico di Angular è che zone.js intercetta ogni evento asincrono (click, timer, fetch) e rilancia il change detection sull'intero albero dei componenti. Su applicazioni grandi questo significa migliaia di controlli inutili per un singolo click.
Le Signals risolvono il problema alla radice: il framework sa esattamente quale componente dipende da quale dato, e aggiorna solo quello.
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]);
}
}
Perché funziona: computed() ricalcola solo quando il signal da cui dipende cambia, e Angular aggiorna solo i nodi DOM legati a quel signal, senza attraversare l'intero componente. In un'app con zone.js classica, lo stesso addItem avrebbe scatenato change detection su tutta la subtree del componente padre.
Bootstrap zoneless (Angular 18+):
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
bootstrapApplication(AppComponent, {
providers: [provideZonelessChangeDetection()],
});
Vantaggi: bundle più piccolo (zone.js pesa ~30KB), meno cicli di change detection, debugging più prevedibile.
Svantaggi: librerie di terze parti che si aspettano zone.js (alcune versioni datate di Material o librerie di grafici) possono richiedere NgZone.run() manuale.
Errore frequente: mescolare signals e mutable object reference — se muti un array con .push() invece di .update(), il signal non rileva il cambiamento.
trackBy e @for con chiave
@Component({
template: `
@for (product of products(); track product.id) {
<app-product-card [product]="product" />
}
`,
})
export class ProductListComponent {
products = signal<Product[]>([]);
}
La nuova sintassi @for richiede un track, a differenza del vecchio *ngFor dove trackBy era opzionale e spesso dimenticato. Senza una chiave stabile, Angular distrugge e ricrea ogni nodo DOM a ogni aggiornamento della lista invece di riordinare quelli esistenti — su una tabella di 500 righe è la differenza tra un aggiornamento istantaneo e uno scatto visibile.
Lazy loading avanzato e preloading strategico
// 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 è comodo ma ingenuo: scarica tutto in background anche se l'utente non ci arriverà mai. Una strategia basata su viewport (tipo quicklink, ispirata a Google) precarica solo i moduli linkati da elementi effettivamente visibili — riduce banda sprecata su mobile.
SSR e hydration incrementale
// 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 />
}
Vantaggi SSR: primo paint quasi istantaneo, ottimo per SEO e Core Web Vitals (LCP). Svantaggi: costo CPU lato server per il rendering, necessità di gestire codice che gira sia su Node che nel browser (niente window non guardato). @defer con on viewport è la tecnica più efficace nel 2026 per componenti pesanti (grafici, editor rich-text, mappe) che non servono al primo render.
Bundle analysis
ng build --configuration production --stats-json
npx webpack-bundle-analyzer dist/frontend/stats.json
Target realistico per un'app enterprise: bundle iniziale sotto i 150KB gzippati. Le cause più comuni di bundle gonfio: import di intere librerie invece di funzioni singole (import _ from 'lodash' invece di import debounce from 'lodash/debounce'), moment.js invece di date-fns, icone SVG importate come componenti invece che come sprite.
Ottimizzare NestJS
Interceptor di compressione e caching HTTP
// 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();
La compressione Brotli (br) riduce payload JSON tipici del 70-80% rispetto al gzip standard, a fronte di un costo CPU leggermente più alto in compressione — accettabile per la maggior parte delle API REST.
Cache con Redis e 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);
}
}
Cosa cachare: risposte pubbliche, lette molto più spesso di quanto vengano scritte (cataloghi prodotto, elenchi articoli blog, configurazioni). Cosa non cachare mai a livello HTTP: risposte che contengono dati specifici dell'utente autenticato senza chiave di cache che includa l'ID utente — è l'errore di sicurezza più comune con CacheInterceptor.
Invalidazione esplicita quando i dati cambiano:
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;
}
Streaming di risposte grandi
@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());
}
Per export di migliaia di righe, caricare tutto in memoria con find() e poi serializzare in JSON può far esplodere l'heap del processo Node. Lo streaming scrive riga per riga mantenendo l'uso di memoria costante, indipendentemente dalla dimensione del dataset.
Validation pipe: attenzione al costo
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
// disabilita su endpoint interni ad altissimo traffico se il payload è già fidato
}));
class-validator con transform: true usa reflect-metadata e reflection a runtime: su endpoint chiamati decine di migliaia di volte al secondo (es. webhook interni, ingestion di eventi) il costo di validazione può diventare misurabile. In quei casi specifici, una validazione manuale leggera (o zod con schema precompilato) è più veloce.
Ottimizzare Node.js a livello di runtime
Event loop: non bloccarlo mai
// ❌ 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 è single-threaded per il codice JavaScript: una funzione sincrona costosa (hashing, parsing di file enormi, compressione custom) blocca tutte le richieste in corso, non solo quella che l'ha generata. I worker threads spostano il lavoro CPU-bound su thread separati, lasciando l'event loop libero di servire I/O.
Cluster mode: usare tutti i core
// 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 produzione preferisco delegare questo a PM2 (pm2 start dist/main.js -i max) o all'orchestratore (repliche Kubernetes) piuttosto che gestire cluster a mano: stesso beneficio, meno codice da manutenere.
Memory management: individuare i leak
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);
}
}
I leak di memoria in Node quasi sempre derivano da: event listener non rimossi, cache in-memory senza limite di dimensione (usa lru-cache con un max, mai una Map che cresce indefinitamente), closure che trattengono riferimenti a oggetti grandi, timer (setInterval) mai cancellati.
Worker threads per lavoro CPU-bound reale
Tabella di decisione:
| Scenario | Soluzione |
|---|---|
| Query al database, chiamata HTTP esterna | async/await normale (I/O-bound, non blocca) |
| Hashing password, parsing PDF, elaborazione immagini | Worker thread o coda di job (BullMQ) |
| Migliaia di piccoli calcoli ripetuti | Worker thread pool (es. piscina) |
| Task lunghi (minuti), non urgenti | Coda job asincrona (Redis + BullMQ), non worker thread inline |
Database: PostgreSQL, indici e connection pooling
Questa è, nella mia esperienza, la sezione che vale più di tutte le altre messe insieme. La maggior parte dei problemi di performance che ho risolto in produzione erano qui, non nel frontend.
Connection pooling corretto
// 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"
Regola pratica: connection_limit per istanza backend ≈ (core CPU del server Postgres × 2) / numero di repliche del backend. Con più repliche NestJS che condividono lo stesso Postgres, usa PgBouncer davanti al database in modalità transaction pooling per evitare di esaurire le connessioni massime di Postgres (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
Indici: la singola ottimizzazione a maggior impatto
-- 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';
Errore frequente: creare un indice per colonna singola quando le query filtrano sempre su due o tre colonne insieme — un indice composito (user_id, status) serve anche le query solo su user_id, ma non il contrario.
N+1: il bug di performance più comune con 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 } });
Su 100 ordini, la versione N+1 esegue 101 round-trip al database. Con una latenza di rete di anche solo 2ms per query, sono 200ms sprecati che un singolo JOIN elimina completamente.
Pagination: cursor-based oltre l'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 costringe Postgres a contare ed escludere 100.000 righe prima di restituire il risultato — su tabelle con milioni di record, il cursor-based pagination è ordini di grandezza più veloce ed è lo standard per feed infiniti e API pubbliche.
Progetto reale: dashboard con cache Redis
Un esempio end-to-end minimale ma completo: una dashboard di ordini che mostra statistiche aggregate, cachate in Redis e invalidate agli eventi di scrittura.
// 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;
}
Lato Angular, la dashboard consuma questo endpoint con resource() (Angular 19+), che gestisce loading/error state senza boilerplate manuale:
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()" />
}
Logging e monitoraggio minimi ma corretti per questo servizio:
// 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 produzione questo va sostituito con logging strutturato (pino) e metriche esportate verso Prometheus/Grafana, ma il principio — misurare la durata di ogni richiesta — è lo stesso.
Ottimizzazioni avanzate
| Tecnica | Quando usarla | Quando evitarla |
|---|---|---|
| CDN per asset statici | Sempre, per immagini/JS/CSS | Mai da evitare in produzione |
| Rate limiting | API pubbliche, endpoint di autenticazione | Endpoint interni a bassa esposizione |
| Redis cache | Letture frequenti su dati poco volatili | Dati che cambiano ad ogni richiesta |
| Worker threads | Task CPU-bound (hashing, immagini) | I/O-bound (già gestito da async/await) |
| Cluster / repliche multiple | Traffico che satura un core singolo | App con basso traffico, overhead inutile |
| Load balancer | Più istanze del backend | Singola istanza, nessun beneficio |
| Database read replica | Letture >> scritture, reportistica pesante | Dataset piccoli, consistenza stretta richiesta |
| Horizontal scaling | Traffico variabile, picchi stagionali | Colli di bottiglia nel database non risolti (scala solo la sintomatica) |
Rate limiting con @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 su più istanze):
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 distribuisce le richieste all'istanza con meno connessioni attive, più efficace di un semplice round-robin quando le richieste hanno durate molto diverse tra loro.
Benchmark: prima e dopo
Numeri raccolti con autocannon (100 connessioni concorrenti, 30 secondi) su un endpoint GET /api/products?page=1 con circa 50.000 righe nella tabella products.
| Metrica | Prima (no cache, no indice, offset pagination) | Dopo (Redis + indice + cursor pagination) |
|---|---|---|
| Tempo di risposta medio | 420ms | 18ms |
| p95 | 890ms | 35ms |
| p99 | 1.450ms | 62ms |
| Richieste/sec | 210 | 4.100 |
| CPU backend (media) | 78% | 22% |
| Memoria RSS | 340MB | 190MB |
| Query al DB per richiesta | 1 (lenta, ~400ms) | ~0.1 (cache hit 90% del tempo) |
Comando usato:
npx autocannon -c 100 -d 30 http://localhost:3000/api/products?page=1
Commento: il salto più grande viene dall'indice composito e dal caching, non da micro-ottimizzazioni di codice. Passare da Express ad Fastify su questo stesso endpoint ha dato un ulteriore +15% di richieste/sec, misurabile ma secondario rispetto al lavoro su database e cache.
Sul frontend, lo stesso principio: attivare zoneless change detection su una dashboard con 300 componenti ha ridotto i cicli di change detection misurati con Angular DevTools Profiler da ~1.200 a ~80 per interazione utente, con un Time to Interactive che è sceso da 3.1s a 1.4s dopo aver aggiunto anche @defer sui grafici sotto la piega.
Sicurezza
Le performance e la sicurezza spesso condividono le stesse contromisure (rate limiting, validazione input), ma vanno trattate esplicitamente.
// 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 e 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 };
}
}
Access token di vita breve (15 minuti) limita la finestra di rischio in caso di furto; il refresh token va salvato hashato lato server e revocabile (logout deve invalidarlo davvero, non solo lato client).
Sanitizzazione e validazione con class-validator:
export class CreateProductDto {
@IsString() @Length(3, 100)
name: string;
@IsNumber() @Min(0)
price: number;
@IsOptional() @IsUrl()
imageUrl?: string;
}
Logging sicuro: non loggare mai password, token, o dati sensibili anche in caso di errore.
// ❌
this.logger.error(`Login fallito per ${dto.email} con password ${dto.password}`);
// ✅
this.logger.error(`Login fallito per utente ${dto.email}`);
Gestione eccezioni centralizzata:
@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
});
}
}
Best practice architetturali
Repository pattern e Dependency Injection (nativo in NestJS): separa la logica di dominio dall'accesso ai dati, rendendo i servizi testabili senza database reale.
@Injectable()
export class ProductsService {
constructor(private readonly repo: ProductsRepository) {} // interfaccia, non implementazione diretta
findFeatured() {
return this.repo.findFeatured();
}
}
Modularizzazione: ogni feature NestJS come modulo indipendente (ProductsModule, OrdersModule) con confini chiari; in Angular, feature standalone components con lazy loading per confine.
Configurazione per ambiente:
// 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),
}),
});
Validare le variabili d'ambiente all'avvio (con Joi o Zod) evita che un typo in produzione si scopra a runtime invece che al deploy.
Osservabilità: correlare log, metriche e trace con un request-id propagato end-to-end (header X-Request-Id), esportato verso uno stack tipo Grafana + Loki + Tempo o equivalente gestito.
20 errori comuni
| # | Errore | Causa | Soluzione |
|---|---|---|---|
| 1 | Query senza indice su colonne filtrate spesso | Nessun EXPLAIN ANALYZE durante lo sviluppo |
Aggiungere indici mirati, verificare con EXPLAIN |
| 2 | N+1 query con ORM | Relazioni caricate in loop | relations/include per join espliciti |
| 3 | Cache senza invalidazione | TTL lungo "per sicurezza" | Invalidazione esplicita agli eventi di scrittura |
| 4 | *ngFor/@for senza track key |
Copia-incolla senza pensare al reflow DOM | Sempre track item.id |
| 5 | Bundle Angular gonfio | Import di librerie intere | Import mirati, tree-shaking, analisi bundle |
| 6 | Blocco dell'event loop | Funzioni sincrone CPU-bound in richieste HTTP | Worker threads o code di job |
| 7 | Pool di connessioni troppo piccolo | Valore di default non rivisto | Dimensionare su core CPU e repliche |
| 8 | Pool di connessioni troppo grande | "Più è meglio" senza calcolo | Rispettare il limite massimo di Postgres, usare PgBouncer |
| 9 | Nessuna compressione HTTP | Dimenticata in fase di setup | compression/@fastify/compress in produzione |
| 10 | OFFSET pagination su tabelle enormi | Pattern più semplice da implementare | Cursor-based pagination |
| 11 | Validazione disabilitata per "velocità" | Percezione errata del costo reale | Misurare prima di disabilitare, usare Zod se serve velocità |
| 12 | Secrets in log | Logging non filtrato | Logger con redaction automatica dei campi sensibili |
| 13 | CORS aperto a * con credenziali |
Copiato da tutorial | Whitelist esplicita di origin |
| 14 | Nessun rate limiting su login | Sottovalutazione del rischio brute-force | @nestjs/throttler con limiti stretti sugli endpoint sensibili |
| 15 | Memory leak da listener non rimossi | EventEmitter.on senza off |
once() dove possibile, cleanup esplicito |
| 16 | Cache in-memory senza limite | Map che cresce indefinitamente |
lru-cache con max esplicito |
| 17 | SSR che accede a window/document |
Codice non guardato per ambiente server | isPlatformBrowser() prima di API browser-only |
| 18 | Nessun indice composito, solo singoli | Aggiunti uno alla volta senza analisi query reali | Indice composito sull'ordine effettivo dei filtri |
| 19 | Worker threads per task I/O-bound | Confusione tra CPU-bound e I/O-bound | async/await per I/O, worker solo per CPU |
| 20 | Nessun timeout su chiamate esterne | Fiducia cieca in servizi terzi | Timeout espliciti + circuit breaker |
FAQ
Fastify è davvero più veloce di Express in NestJS? Sì, generalmente 15-30% di richieste/sec in più a parità di logica, grazie a un router e parsing più efficienti. Il guadagno reale dipende da quanto tempo la tua app passa nel framework HTTP vs nel tuo codice/database.
Devo sempre usare Redis per il caching?
No. Su applicazioni piccole con basso traffico, una cache in-memory (lru-cache) può bastare ed evita l'infrastruttura aggiuntiva. Redis diventa necessario quando hai più istanze del backend che devono condividere la stessa cache.
Zoneless Angular è pronto per produzione nel 2026? Sì per progetti nuovi o con dipendenze aggiornate. Verifica prima che le librerie di terze parti critiche per il tuo progetto non richiedano zone.js internamente.
Qual è la differenza tra OFFSET e cursor pagination? OFFSET salta N righe ad ogni pagina (costo crescente con N); cursor pagination usa l'ultimo valore visto come punto di partenza (costo costante, richiede un ordinamento stabile).
Quanti worker threads devo creare?
Non più del numero di core CPU disponibili (os.availableParallelism()); oltre quel numero non guadagni parallelismo reale, solo overhead di context switching.
Come scelgo il TTL giusto per una cache? In base a quanto i dati cambiano e a quanto è tollerabile mostrare un dato leggermente vecchio. Dati di catalogo: minuti. Dati finanziari o di inventario in tempo reale: secondi o invalidazione esplicita, mai TTL lungo.
Devo usare TypeORM o Prisma? Prisma offre tipizzazione più forte e query builder più ergonomico; TypeORM è più maturo su pattern Active Record/Data Mapper avanzati. Per progetti nuovi nel 2026 tendo a preferire Prisma per la developer experience.
Il cluster mode di Node duplica anche la memoria? Sì, ogni worker è un processo separato con il proprio heap. Se la tua app usa 200MB per istanza, 4 worker significano ~800MB totali: pianifica la memoria del server di conseguenza.
Quando conviene una read replica di Postgres? Quando il carico di lettura (dashboard, report) inizia a competere con le scritture transazionali sulla stessa istanza. Non risolve un database mal indicizzato, sposta solo il problema.
Come faccio profiling reale di un'API NestJS?
node --prof per CPU profiling, Chrome DevTools per heap snapshot, EXPLAIN ANALYZE per query lente, autocannon/k6 per load testing end-to-end.
Helmet basta per la sicurezza HTTP? È un buon punto di partenza (header CSP, HSTS, ecc.) ma va integrato con validazione input, rate limiting, gestione corretta di JWT e HTTPS obbligatorio.
Il rate limiting va messo a livello di Nginx o di NestJS?
Idealmente entrambi: Nginx per una prima difesa grezza contro traffico anomalo, @nestjs/throttler per logica fine per-endpoint e per-utente.
Quanto incide realmente la compressione Brotli rispetto a gzip? Su payload JSON tipici, 10-20% di riduzione dimensione aggiuntiva rispetto a gzip, a fronte di CPU leggermente più alta in compressione (la decompressione lato client è comparabile).
@defer sostituisce completamente il lazy loading delle route?
No, sono complementari: il lazy loading delle route riduce il bundle iniziale per intere sezioni dell'app, @defer riduce il costo di rendering di singoli componenti pesanti dentro una pagina già caricata.
Come evito che la cache Redis diventi un single point of failure? Redis in modalità Sentinel o Cluster per alta disponibilità; a livello applicativo, la cache deve essere un'ottimizzazione, non una dipendenza hard — se Redis è giù, l'app deve poter comunque servire (più lentamente) dal database.
È meglio scalare verticalmente o orizzontalmente? Verticale (più CPU/RAM su una singola istanza) è più semplice ma ha un tetto e un single point of failure. Orizzontale (più repliche) scala meglio ma richiede che l'app sia stateless e il database non diventi il collo di bottiglia.
Quanto spesso devo rivedere gli indici del database?
Ogni volta che cambiano i pattern di query dominanti (nuove feature, nuovi filtri in dashboard) e periodicamente con pg_stat_user_indexes per trovare indici mai usati che rallentano le scritture inutilmente.
I signal di Angular sostituiscono RxJS?
Non completamente: i signal sono ottimi per stato sincrono locale ai componenti; RxJS resta più adatto per flussi asincroni complessi (debounce, retry, combinazione di stream multipli). Angular moderno li fa interoperare con toSignal()/toObservable().
Qual è l'errore più costoso che vedo ripetersi nei progetti NestJS? Query N+1 non rilevate finché il dataset di produzione non cresce oltre quello di test — funziona bene con 50 righe, va in timeout con 50.000.
Serve sempre un CDN anche per un piccolo progetto? Sì per gli asset statici (JS, CSS, immagini): il costo di setup è basso (spesso gratuito) e il beneficio in latenza globale è immediato, indipendentemente dalla scala del progetto.
Conclusione
Le performance non sono una feature che si aggiunge alla fine: sono la somma di scelte fatte durante tutto lo sviluppo, dall'indice sulla tabella giusta al track dimenticato in un @for. I punti che fanno davvero la differenza, in ordine di impatto tipico:
- Database — indici corretti e connection pooling dimensionato risolvono la maggior parte dei colli di bottiglia backend.
- Caching mirato — Redis su letture frequenti, con invalidazione esplicita, non TTL alla cieca.
- Change detection efficiente — signals e
@defersu Angular per ridurre lavoro di rendering inutile. - Non bloccare l'event loop — worker threads per CPU-bound, mai per I/O.
- Misurare prima di ottimizzare —
EXPLAIN ANALYZE, profiler, benchmark reali, non supposizioni.
Il prossimo passo naturale da approfondire è l'osservabilità end-to-end (tracing distribuito con OpenTelemetry) e le strategie di scaling automatico su Kubernetes, che meritano un articolo a parte.
Se questa guida ti è stata utile, condividila con il tuo team, lascia un commento con la tecnica che ha avuto il maggiore impatto sul tuo progetto, e iscriviti alla newsletter per non perdere i prossimi approfondimenti su performance e architettura full-stack.