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
- Why performance matters
- How the request-response flow works
- Prerequisites and reference stack
- Optimize Angular
- Optimize NestJS
- Optimize Node.js at the runtime level
- Database: PostgreSQL, indexes and connection pooling
- Real project: Dashboard with Redis cache
- Advanced optimizations
- Benchmark: before and after
- Security
- Architectural best practices
- 20 common errors
- FAQ
- 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:
- Database — correct indexes and sized connection pooling solve most backend bottlenecks.
- Targeted caching — Redis on frequent reads, with explicit invalidation, not blind TTL.
- Efficient change detection — signals and
@deferon Angular to reduce unnecessary rendering work. - Don't block event loop — worker threads for CPU-bound, never for I/O.
- Measure before optimizing —
EXPLAIN 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.