Una aplicación lenta de pila completa casi nunca es culpa de "un marco lento". Casi siempre es la suma de diez pequeñas malas decisiones: un *ngFor sin trackBy, una consulta sin índice, un punto final que no comprime la respuesta, un grupo de conexiones de tamaño aleatorio. Tomados individualmente parecen detalles. En conjunto, son la diferencia entre una aplicación que responde en 80 ms y una que tarda 2 segundos bajo carga.
Esta guía recopila las técnicas que realmente uso en producción en Angular + NestJS + Node.js + PostgreSQL + Redis stack, con código completo, números de referencia realistas y los errores más comunes que he visto (y cometido) en los últimos años.
Índice
- Por qué es importante el rendimiento
- Cómo funciona el flujo de solicitud-respuesta
- Requisitos previos y pila de referencia
- Optimizar Angular
- Optimizar NestJS
- Optimizar Node.js en el nivel de tiempo de ejecución
- Base de datos: PostgreSQL, índices y agrupación de conexiones
- Proyecto real: Panel con caché de Redis
- Optimizaciones avanzadas
- Benchmark: antes y después
- Seguridad
- Mejores prácticas arquitectónicas
- 20 errores comunes
- Preguntas frecuentes
- Conclusión
Por qué es importante el rendimiento
Qué es, en la práctica. Optimizar el rendimiento de una pila Angular/NestJS/Node.js significa reducir tres números: el tiempo percibido por el usuario (Core Web Vitals en el lado del frontend), el tiempo de respuesta del backend (p50/p95/p99) y los recursos consumidos para atender cada solicitud (CPU, memoria, conexiones). DB).
Por qué es importante. Google utiliza Core Web Vitals en la clasificación. Amazon midió que cada 100 ms adicionales de latencia cuesta puntos porcentuales de conversión. Pero la razón más concreta, la que veo todos los días, es otra: un backend que no escala linealmente te obliga a comprar hardware en lugar de escribir un código mejor, y en cierto punto el hardware ya no es suficiente.
Cuándo aplicarlo. No inmediatamente. Optimizar prematuramente un punto final llamado 10 veces al día es una pérdida de tiempo. Las técnicas de esta guía deben aplicarse cuando: tiene datos de perfiles reales (no conjeturas), el tráfico está creciendo o un punto final específico aparece en los registros como un cuello de botella.
Beneficios. Tiempos de respuesta más bajos, costos de infraestructura reducidos, mejor SEO, menor abandono de usuarios, capacidad para manejar picos de tráfico sin tiempo de inactividad.
Desventajas. Cada optimización tiene un costo: más complejidad (caché para invalidar, trabajadores para orquestar), más superficie de errores, tiempo de desarrollo. El almacenamiento en caché mal administrado es la principal causa de que los datos obsoletos se muestren a los usuarios.
Errores comunes. Optimizar sin medir primero; copiar técnicas de blogs sin comprender las ventajas y desventajas; ignorando la base de datos (la causa número uno de lentitud en la pila NestJS) mientras pasa semanas en microoptimizaciones de Angular.
Casos de uso reales. Un e-commerce que pasa de 3s a 400ms en la página del producto aumentando las conversiones en un 15%; un panel interno que se agota con 200 usuarios simultáneos porque el grupo de Postgres está configurado en 5 conexiones; una API pública que maneja 10 veces el tráfico después de agregar Redis frente a las consultas más pesadas.
Cómo funciona el flujo de solicitud-respuesta
Antes de optimizar, necesita un modelo mental de dónde se gasta realmente el tiempo en una pila 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)
Diagrama de secuencia para una solicitud típica (por ejemplo, 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
Cada flecha en este diagrama es un lugar donde puedes perder (o ganar) milisegundos. Las siguientes secciones los atacan uno por uno.
Requisitos previos y pila de referencia
Para seguir los ejemplos de esta guía necesitas:
| Herramienta | Versión recomendada (2026) | Notas |
|---|---|---|
| Node.js | 22 LTS o superior | Soporte nativo para subprocesos de trabajo y recuperación |
| Angular | 19+ | Señales estables, sin zona opcional |
| NestJS | 11+ | Soporte Fastify como adaptador alternativo a Express |
| PostgreSQL | 16+ | Mejores estadísticas del planificador, consultas paralelas |
| Redis | 7+ | Soporte para funciones granulares y ACL |
| Docker/Docker Compose | último establo | Entorno local reproducible |
| pnpm | 9+ | Instalación más rápida que npm, eficiente en disco |
| Prisma o TypeORM | último establo | ORM escrito |
No necesitas todo junto desde el primer día. Si estás leyendo para optimizar un proyecto existente, la sección de base de datos (índices, pooling) casi siempre debe aplicarse antes que todo lo demás: es donde se esconde la mayor ganancia con el menor riesgo.
Instalación rápida del entorno de referencia
# 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 reemplaza Express como motor HTTP: con la misma lógica de aplicación, Fastify maneja entre un 20 y un 30% más de solicitudes por segundo gracias a un enrutador más eficiente y un análisis JSON; vale la pena adoptarlo en nuevos proyectos.
Optimizar Angular
Detección y señales de cambio sin zona
El problema histórico de Angular es que zone.js intercepta cada evento asincrónico (clic, temporizador, recuperación) y reinicia la detección de cambios en todo el árbol de componentes. En aplicaciones grandes, esto significa miles de controles inútiles con un solo clic.
Las señales resuelven el problema desde la raíz: el marco sabe exactamente qué componente depende de qué datos y solo actualiza eso.
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]);
}
}
Por qué funciona: computed() solo recalcula cuando la señal de la que depende cambia, y Angular solo actualiza los nodos DOM vinculados a esa señal, sin atravesar todo el componente. En una aplicación con el Zone.js clásico, el mismo addItem habría activado la detección de cambios en todo el subárbol del componente principal.
Bootstrap sin zona (Angular 18+):
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
bootstrapApplication(AppComponent, {
providers: [provideZonelessChangeDetection()],
});
Ventajas: paquete más pequeño (zone.js pesa ~30 KB), menos ciclos de detección de cambios, depuración más predecible.
Contras: Las bibliotecas de terceros que esperan Zone.js (algunas versiones anteriores de bibliotecas de materiales o gráficos) pueden requerir el manual NgZone.run().
Error común: mezclar señales y referencia de objeto mutable: si muta una matriz con .push() en lugar de .update(), la señal no detecta el cambio.
trackBy y @for con clave
@Component({
template: `
@for (product of products(); track product.id) {
<app-product-card [product]="product" />
}
`,
})
export class ProductListComponent {
products = signal<Product[]>([]);
}
La nueva sintaxis @for requiere una track, a diferencia de la antigua *ngFor donde trackBy era opcional y a menudo se olvidaba. Sin una clave estable, Angular destruye y recrea cada nodo DOM con cada actualización de la lista en lugar de reordenar los existentes; en una tabla de 500 filas, es la diferencia entre una actualización instantánea y un clic visible.
Carga diferida avanzada y precarga estratégica
// 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 es conveniente pero ingenuo: descarga todo en segundo plano incluso si el usuario nunca llega allí. Una estrategia basada en ventana gráfica (como enlace rápido, inspirada en Google) precarga solo formularios vinculados por elementos realmente visibles, lo que reduce el desperdicio de ancho de banda en dispositivos móviles.
SSR e hidratación incremental
// 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 />
}
Beneficios de SSR: Primera pintura casi instantánea, excelente para SEO y Core Web Vitals (LCP). Contras: costo de CPU del lado del servidor para renderizar, es necesario administrar el código que se ejecuta tanto en Node como en el navegador (nada ventana no mirado). @defer con en la ventana gráfica es la técnica más efectiva en 2026 para componentes pesados (gráficos, editores de texto enriquecido, mapas) que no son necesarios para el primer renderizado.
Análisis de paquetes
ng build --configuration production --stats-json
npx webpack-bundle-analyzer dist/frontend/stats.json
Objetivo realista para una aplicación empresarial: paquete inicial de menos de 150 KB comprimido con gzip. Las causas más comunes de hinchazón de paquetes: importación de bibliotecas enteras en lugar de funciones individuales (import _ from 'lodash' en lugar de import antirrebote de 'lodash/debounce'), moment.js en lugar de date-fns, íconos SVG importados como componentes en lugar de sprites.
Optimizar NestJS
Interceptor de almacenamiento en caché y compresión 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 compresión Brotli (br) reduce las cargas útiles JSON típicas entre un 70 y un 80% en comparación con el gzip estándar, con un costo de CPU en compresión ligeramente mayor, aceptable para la mayoría de las API REST.
Caché con Redis y administrador de caché
// 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);
}
}
Qué almacenar en caché: respuestas públicas, leídas con mucha más frecuencia de lo que se escriben (catálogos de productos, listas de artículos de blogs, configuraciones). Qué no almacenar nunca en caché en la capa HTTP: respuestas que contienen datos específicos del usuario autenticado sin una clave de caché que incluya el ID del usuario: es el error de seguridad más común con CacheInterceptor.
Invalidación explícita cuando los datos cambian:
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;
}
Transmisión de respuestas grandes
@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());
}
Para exportar miles de filas, cargar todo en la memoria con find() y luego serializar a JSON puede hacer explotar el montón de procesos de Node. La transmisión escribe línea por línea manteniendo constante el uso de la memoria, independientemente del tamaño del conjunto de datos.
Tubería de validación: preste atención 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 y reflexión en tiempo de ejecución: en puntos finales llamados decenas de miles de veces por segundo (por ejemplo, webhooks internos, ingestión de eventos) El costo de validación puede llegar a ser mensurable. En esos casos específicos, una validación manual ligera (o zod con un esquema precompilado) es más rápida.
Optimizar Node.js en el nivel de tiempo de ejecución
Bucle de eventos: nunca bloquearlo
// ❌ 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);
});
}
Nodo. js es de un solo subproceso para código JavaScript: una costosa función síncrona (hashing, análisis de archivos enormes, compresión personalizada) bloquea todas las solicitudes en curso, no solo la que la generó. Los subprocesos de trabajo mueven el trabajo vinculado a la CPU a subprocesos separados, dejando el bucle de eventos libre para servir E/S.
Modo clúster: usa todos los núcleos
// 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
}
En producción, prefiero delegar esto a PM2 (pm2 start dist/main.js -i max) o al orquestador (réplicas de Kubernetes) en lugar de administrar clusters a mano: mismo beneficio, menos código que mantener.
Gestión de memoria: detectar fugas
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);
}
}
Las pérdidas de memoria en Node casi siempre surgen de: detectores de eventos no eliminados, caché en memoria sin límite de tamaño (use lru-cache con un max, nunca un Map que crece indefinidamente), cierre que contiene referencias a objetos grandes, temporizadores (setInterval) nunca se borran.
Subprocesos de trabajo para trabajo real vinculado a la CPU
Tabla de decisiones:
| Escenario | Solución |
|---|---|
| Consulta de base de datos, llamada HTTP externa | async/await normal (vinculado a E/S, no bloquea) |
| Hashing de contraseñas, análisis de PDF, procesamiento de imágenes | Hilo de trabajo o cola de trabajos (BullMQ) |
| Miles de pequeños cálculos repetidos | Grupo de subprocesos de trabajo (p. ej.
piscina) |
| Tareas largas (minutos), no urgentes | Cola de trabajos asincrónicos (Redis + BullMQ), hilo no trabajador en línea |
Base de datos: PostgreSQL, índices y agrupación de conexiones
Ésta es, según mi experiencia, la sección que vale más que todas las demás juntas. La mayoría de los problemas de rendimiento que solucioné en producción estaban aquí, no en la interfaz.
Agrupación de conexiones fija
// 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"
Regla general: connection_limit por instancia de backend ≈ (núcleos de CPU del servidor Postgres × 2) / número de réplicas de backend. Con varias réplicas de NestJS que comparten el mismo Postgres, use PgBouncer frente a la base de datos en transaction modo de agrupación para evitar agotar el máximo de conexiones de Postgres (predeterminado 100).
# pgbouncer.ini (estratto)
[databases]
app = host=postgres port=5432 dbname=app
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
Índices: la optimización más impactante
-- 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';
Error común: cree un índice de una sola columna cuando las consultas siempre se filtran en dos o tres columnas juntas; un índice compuesto (user_id, status) también atiende consultas solo en user_id, pero no en enfrente.
N+1: el error de rendimiento más común 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 } });
De 100 pedidos, la versión N+1 realiza 101 viajes de ida y vuelta a la base de datos. Con una latencia de red tan baja como 2 ms por consulta, son 200 ms desperdiciados que un solo JOIN elimina por completo.
Paginación: basada en cursor más allá del desplazamiento
// ❌ 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 obliga a Postgres a contar y excluir 100.000 filas antes de devolver el resultado; en tablas con millones de registros, la paginación basada en cursores es mucho más rápida y es el estándar para feeds infinitos y API públicas.
Proyecto real: Panel con Redis Cache
Un ejemplo mínimo pero completo de principio a fin: un panel de pedidos que muestra estadísticas agregadas, almacenadas en caché en Redis e invalidadas en eventos de escritura.
// 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;
}
Lado angular, el tablero consume este punto final con resource() (Angular 19+), que maneja el estado de carga/error sin texto repetitivo manual:
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()" />
}
Registro y monitoreo mínimos pero correctos para este servicio:
// 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`)),
);
}
}
En producción, esto debería reemplazarse con registros estructurados (pine) y métricas exportadas a Prometheus/Grafana, pero el principio (medir la duración de cada solicitud) es el mismo.
Optimizaciones avanzadas
| Técnica | Cuándo usarlo | Cuándo evitarlo |
|---|---|---|
| CDN para activos estáticos | Siempre, para imágenes/JS/CSS | Nunca se debe evitar en producción |
| Limitación de velocidad | API públicas, puntos finales de autenticación | Puntos finales internos de baja exposición |
| Caché de Redis | Lecturas frecuentes sobre datos menos volátiles | Datos que cambian con cada solicitud |
| Hilos de trabajo | Tareas vinculadas a la CPU (hash, imágenes) | I/O-bound (ya manejado por async/await) |
| Clústeres/Múltiples réplicas | Tráfico que satura un solo núcleo | Aplicación de poco tráfico, gastos generales innecesarios |
| Equilibrador de carga | Múltiples instancias de backend | Instancia única, sin beneficio |
| Réplica de lectura de base de datos | Lecturas >> escritos, informes pesados | Conjuntos de datos pequeños, se requiere coherencia estricta |
| Escalado horizontal | Tráfico variable, picos estacionales | Cuellos de botella de base de datos no resueltos (solo escala sintomática) |
Limitación de velocidad 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) { /* ... */ }
Balanceador de carga (Nginx, ascendente a múltiples instancias):
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 distribuye solicitudes a la instancia con menos conexiones activas, más efectivo que un simple round-robin cuando las solicitudes tienen duraciones muy diferentes.
Benchmark: antes y después
Números recopilados con autocannon (100 conexiones simultáneas, 30 segundos) en un punto final GET /api/products?page=1 con aproximadamente 50.
000 filas en la tabla productos.
| Métrico | Antes (sin caché, sin índice, paginación desplazada) | Después (Redis + índice + paginación del cursor) |
|---|---|---|
| Tiempo medio de respuesta | 420ms | 18ms |
| p95 | 890ms | 35ms |
| p99 | 1.450ms | 62ms |
| Solicitudes/seg | 210 | 4.100 |
| CPU backend (promedio) | 78% | 22% |
| Memoria RSS | 340MB | 190MB |
| Consulta a la base de datos para solicitud | 1 (lento, ~400 ms) | ~0.1 (alcance de caché el 90% del tiempo) |
Comando utilizado:
npx autocannon -c 100 -d 30 http://localhost:3000/api/products?page=1
Comentario: El mayor salto proviene del índice compuesto y el almacenamiento en caché, no de las microoptimizaciones de código. El cambio de Express a Fastify en este mismo punto final generó un aumento adicional del 15 % en solicitudes por segundo, lo que es mensurable pero secundario en comparación con el trabajo en la base de datos y el caché.
En el frontend, el mismo principio: activar la detección de cambios sin zonas en un panel con 300 componentes redujo los ciclos de detección de cambios medidos con Angular DevTools Profiler de ~1200 a ~80 por interacción del usuario, con un tiempo de interacción que cayó de 3,1 a 1,4 segundos después de agregar también @defer en los gráficos debajo de doblar.
Seguridad
El rendimiento y la seguridad a menudo comparten las mismas contramedidas (limitación de velocidad, validación de entradas), pero deben abordarse explícitamente.
// 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 y token de actualización:
@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 };
}
}
El token de acceso de corta duración (15 minutos) limita la ventana de riesgo en caso de robo; el token de actualización debe guardarse con hash en el lado del servidor y ser revocable (el cierre de sesión realmente debe invalidarlo, no solo en el lado del cliente).
Sanitización y validación con class-validator:
export class CreateProductDto {
@IsString() @Length(3, 100)
name: string;
@IsNumber() @Min(0)
price: number;
@IsOptional() @IsUrl()
imageUrl?: string;
}
Registro seguro: nunca registre contraseñas, tokens o datos confidenciales, incluso en caso de error.
// ❌
this.logger.error(`Login fallito per ${dto.email} con password ${dto.password}`);
// ✅
this.logger.error(`Login fallito per utente ${dto.email}`);
Manejo de excepciones centralizado:
@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
});
}
}
Mejores prácticas arquitectónicas
Patrones de repositorio e inyección de dependencia (nativo en NestJS): Separa la lógica de dominio del acceso a datos, lo que hace que los servicios se puedan probar sin una base de datos real.
@Injectable()
export class ProductsService {
constructor(private readonly repo: ProductsRepository) {} // interfaccia, non implementazione diretta
findFeatured() {
return this.repo.findFeatured();
}
}
Modularización: cada función NestJS como un módulo independiente (ProductsModule, OrdersModule) con límites claros; en Angular, presenta componentes independientes con carga diferida por límite.
Configuración por entorno:
// 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),
}),
});
Validar las variables de entorno al inicio (con Joi o Zod) evita que se descubra un error tipográfico en producción en tiempo de ejecución en lugar de durante la implementación.
Observabilidad: correlaciona registros, métricas y seguimientos con un request-id propagado de extremo a extremo (encabezado X-Request-Id), exportado a una pila como Grafana + Loki + Tempo o equivalente gestionado.
20 errores comunes
explícito| # | Error | Causa | Solución |
|---|---|---|---|
| 1 | Consultas sin índice en columnas filtradas con frecuencia | No EXPLICAR ANALIZAR durante el desarrollo |
Agregue índices específicos, consulte con EXPLAIN |
| 2 | N+1 consulta con ORM | Relaciones cargadas en bucle | relations/include para uniones explícitas |
| 3 | Caché sin invalidación | TTL largo "por seguridad" | Invalidación explícita en eventos de escritura |
| 4 | *ngFor/@for sin clave de seguimiento |
Copiar y pegar sin pensar en el reflujo DOM | Siempre rastrear elemento.
identificación |
| 5 | Paquete hinchado angular | Importación de bibliotecas enteras | Importaciones específicas, sacudidas de árboles, análisis de paquetes |
| 6 | Bloqueo de bucle de eventos | Funciones síncronas vinculadas a la CPU en solicitudes HTTP | Hilos de trabajo o colas de trabajos |
| 7 | Grupo de conexiones demasiado pequeño | Valor predeterminado no revisado | Tamaño de núcleos de CPU y réplicas |
| 8 | Grupo de conexiones demasiado grande | "Más es mejor" sin cálculo | Respete el límite máximo de Postgres, use PgBouncer |
| 9 | Sin compresión HTTP | Olvidado durante la configuración | compression/@fastify/compress en producción |
| 10 | Paginación OFFSET en tablas enormes | Patrón más fácil de implementar | Paginación basada en cursor |
| 11 | Validación deshabilitada para "velocidad" | Percepción incorrecta del coste real | Mida antes de desactivar, use Zod si necesita velocidad |
| 12 | Secretos en el registro | Registro sin filtrar | Registrador con redacción automática de campos sensibles |
| 13 | CORS abierto a * con credenciales |
Copiado del tutorial | Lista blanca explícita de origen |
| 14 | Sin límite de velocidad al iniciar sesión | Subestimación del riesgo de fuerza bruta | @nestjs/throttler con límites estrictos en puntos finales sensibles |
| 15 | Pérdida de memoria de los oyentes no eliminada | EventEmitter.
en sin off |
once() cuando sea posible, limpieza explícita |
| 16 | Caché en memoria ilimitada | Mapa creciendo indefinidamente |
lru-cache con max |
| 17 | SSR accediendo a ventana/document |
Código no examinado para el entorno del servidor | isPlatformBrowser() antes de la API de solo navegador |
| 18 | Sin índice compuesto, solo sencillos | Agregado uno a la vez sin análisis de consulta real | Índice compuesto sobre el orden real de los filtros |
| 19 | Subprocesos de trabajo para tareas vinculadas a E/S | Confusión entre CPU vinculada y E/S vinculada | async/await para E/S, trabajador solo para CPU |
| 20 | Sin tiempo de espera en llamadas externas | Confianza ciega en servicios de terceros | Tiempos de espera explícitos + disyuntor |
Preguntas frecuentes
¿Fastify es realmente más rápido que Express en NestJS? Sí, generalmente entre un 15% y un 30% más de solicitudes por segundo con la misma lógica, gracias a un enrutador y un análisis más eficientes. El beneficio real depende de cuánto tiempo pasa su aplicación en el marco HTTP versus en su código/base de datos.
¿Debo usar siempre Redis para el almacenamiento en caché?
No. En aplicaciones pequeñas con poco tráfico, una caché en memoria (lru-cache) puede ser suficiente y evita infraestructura adicional. Redis se vuelve necesario cuando tienes varias instancias de backend que necesitan compartir el mismo caché.
¿Está Zoneless Angular listo para producción en 2026? Sí para proyectos nuevos o con dependencias actualizadas. Primero verifique que las bibliotecas de terceros críticas para su proyecto no requieran Zone.js internamente.
¿Cuál es la diferencia entre OFFSET y paginación del cursor? OFFSET salta N líneas en cada página (el costo aumenta con N); La paginación del cursor utiliza el último valor visto como punto de partida (coste constante, requiere una clasificación estable).
¿Cuántos subprocesos de trabajo debo crear?
No más que el número de núcleos de CPU disponibles (os.availableParallelism()); más allá de ese número no se obtiene ningún paralelismo real, solo cambios de contexto.
¿Cómo elijo el TTL correcto para un caché? Dependiendo de cuánto cambien los datos y de cuánto sea tolerable mostrar datos ligeramente antiguos. Datos del catálogo: minutos. Datos financieros o de inventario en tiempo real: segundos o invalidación explícita, nunca TTL largos.
¿Debo usar TypeORM o Prisma? Prisma ofrece una escritura más sólida y un generador de consultas más ergonómico; TypeORM es más maduro en patrones avanzados de Active Record/Data Mapper. Para nuevos proyectos en 2026, tiendo a preferir Prisma por la experiencia del desarrollador.
¿El modo de clúster de Node también duplica la memoria? Sí, cada trabajador es un proceso independiente con su propio montón. Si su aplicación usa 200 MB por instancia, 4 trabajadores significan ~800 MB en total; planifique la memoria de su servidor en consecuencia.
¿Cuándo vale la pena una réplica de lectura de Postgres? Cuando la carga de lectura (panel de control, informes) comienza a competir con las escrituras transaccionales en la misma instancia. No soluciona una base de datos mal indexada, simplemente soluciona el problema.
¿Cómo puedo crear un perfil de una API NestJS?
node --prof para creación de perfiles de CPU, Chrome DevTools para instantáneas de montón, EXPLAIN ANALYZE para consultas lentas, autocannon/k6 para pruebas de carga de un extremo a otro.
¿Es suficiente el casco para la seguridad HTTP? Es un buen punto de partida (encabezado CSP, HSTS, etc.) pero debe integrarse con validación de entrada, limitación de velocidad, gestión correcta de JWT y HTTPS obligatorio.
¿Debería colocarse la limitación de velocidad en el nivel de Nginx o NestJS?
Idealmente ambos: Nginx para una primera defensa tosca contra el tráfico anómalo, @nestjs/throttler para una lógica precisa por punto final y por usuario.
¿Qué importancia tiene realmente la compresión Brotli en comparación con gzip? En cargas útiles JSON típicas, se reduce entre un 10 % y un 20 % el tamaño adicional en comparación con gzip, con una CPU ligeramente mayor en compresión (la descompresión del lado del cliente es comparable).
@defer reemplaza completamente la carga diferida de rutas?
No, son complementarios: la carga diferida de rutas reduce el paquete inicial para secciones enteras de la aplicación, @defer reduce el costo de renderizar componentes pesados individuales dentro de una página ya cargada.
¿Cómo evito que la caché de Redis se convierta en un único punto de falla? Redis en modo Sentinel o Cluster para alta disponibilidad; a nivel de aplicación, el almacenamiento en caché debería ser una optimización, no una dependencia estricta: si Redis no funciona, la aplicación aún debería poder funcionar (más lento) desde la base de datos.
¿Es mejor escalar vertical u horizontalmente? Vertical (múltiples CPU/RAM en una sola instancia) es más simple pero tiene un límite y un único punto de falla. La escala horizontal (más réplicas) se escala mejor, pero requiere que la aplicación no tenga estado y que la base de datos no se convierta en un cuello de botella.
¿Con qué frecuencia debo revisar los índices de las bases de datos?
Cada vez que cambian los patrones de consulta dominantes (nuevas funciones, nuevos filtros en el panel) y periódicamente con pg_stat_user_indexes para encontrar índices nunca utilizados que ralentizan las escrituras innecesariamente.
¿Las señales angulares reemplazan a RxJS?
No del todo: las señales son excelentes para el estado síncrono local de los componentes; RxJS sigue siendo el más adecuado para transmisiones asincrónicas complejas (antirrebote, reintento, combinación de múltiples transmisiones). Modern Angular los hace interoperar con toSignal()/toObservable().
¿Cuál es el error más costoso que veo recurrente en los proyectos NestJS? N+1 consultas no detectadas hasta que el conjunto de datos de producción crece más allá del conjunto de datos de prueba: funciona bien con 50 filas, se agota el tiempo de espera con 50 000.
¿Siempre necesitas una CDN incluso para un proyecto pequeño? Sí, para activos estáticos (JS, CSS, imágenes): el costo de configuración es bajo (a menudo gratuito) y el beneficio de latencia general es inmediato, independientemente de la escala del proyecto.
Conclusión
Performance no es una característica que se agrega al final: es la suma de las elecciones realizadas a lo largo del desarrollo, desde el índice en la tabla de la derecha hasta la track olvidada en un @for. Los puntos que realmente marcan la diferencia, en orden de impacto típico:
- Base de datos: los índices correctos y la agrupación de conexiones de tamaño resuelven la mayoría de los cuellos de botella del backend.
- Caché dirigido — Redis en lecturas frecuentes, con invalidación explícita, TTL no ciego.
- Detección de cambios eficiente - señales y
@deferen Angular para reducir el trabajo de renderizado innecesario. - No bloquear el bucle de eventos: subprocesos de trabajo para CPU vinculados, nunca para E/S.
- Mida antes de optimizar —
EXPLICAR ANALIZAR, generador de perfiles, puntos de referencia reales, no conjeturas.
El siguiente paso natural a explorar es la observabilidad de extremo a extremo (rastreo distribuido con OpenTelemetry) y las estrategias de escalado automático en Kubernetes, que merecen un artículo aparte.
Si esta guía te resultó útil, compártela con tu equipo, deja un comentario con la técnica que tuvo mayor impacto en tu proyecto y suscríbete al boletín para no perderte las próximas ideas sobre rendimiento y arquitectura full-stack.