Rendimiento en NestJS
Rendimiento en NestJS
Un backend NestJS no es lento por culpa del framework: NestJS es una capa fina de decoradores y DI sobre Node.js. La lentitud casi siempre viene de cómo usás Node: bloqueás el event loop con trabajo síncrono pesado, resolvés lookups en O(n²), traés datasets enteros a memoria, no cacheás lo caro y devolvés respuestas gigantes sin paginar. Este capítulo ataca esos problemas con mecánica concreta.
La regla mental de todo el capítulo: Node atiende miles de conexiones con un solo hilo de JavaScript. Todo lo que hagas en ese hilo lo pagan todas las requests en curso. Rendimiento en Node es, ante todo, no monopolizar ese hilo y no traer a memoria más de lo necesario.
Fastify vs Express: el adapter importa
NestJS abstrae el servidor HTTP detrás de un adapter. Por defecto usa Express, pero podés cambiarlo a Fastify sin tocar controladores, servicios ni la mayoría de guards, pipes o interceptores.
Fastify rinde casi el doble de throughput que Express en los benchmarks oficiales de NestJS. La diferencia sale de un router más rápido, serialización JSON basada en esquemas y menos overhead por request. En APIs con mucho tráfico JSON, ese factor se nota directo en requests por segundo y en latencia de cola (p99).
Migrar a Fastify
// main.ts — antes (Express, implícito por defecto)
const app = await NestFactory.create(AppModule);
// después (Fastify)
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ trustProxy: true }),
);
await app.listen({ port: Number(process.env.PORT ?? 3000), host: '0.0.0.0' });
}
bootstrap();
Instalás @nestjs/platform-fastify y quitás @nestjs/platform-express. El grueso del código de negocio no cambia porque trabajás contra las abstracciones de Nest (@Body(), @Query(), DTOs), no contra el objeto de request nativo.
Incompatibilidades a tener en cuenta
Fastify no es Express con otro nombre. Antes de migrar, revisá:
- Objeto de request/response nativo: si en algún lado usás
@Req()/@Res()y tocás la API de Express (res.status().json(),res.redirect()), esos accesos cambian. La API de Fastify es distinta (reply.code(),reply.send()). La solución de fondo es no depender del objeto nativo: devolvé valores desde el handler y dejá que Nest serialice. - Middleware: Fastify no usa middleware de Express. Nest expone los objetos crudos vía el paquete
middie, y tu middleware recibereq: FastifyRequest['raw']yres: FastifyReply['raw']. Middleware pensado para Express (por ejemplo, ciertos parsers) puede no funcionar. - Plugins: usás el ecosistema
@fastify/*(por ejemplo@fastify/compress,@fastify/helmet) en lugar de los paquetes de Express equivalentes. rawBody(webhooks con verificación de firma): se activa con la opción{ rawBody: true }enNestFactory.create, y accedés conRawBodyRequest<FastifyRequest>.- Streaming de archivos: Fastify puede enviar streams sin
StreamableFile, pero Nest ofreceStreamableFilepara ambos adapters, así que si lo usás, tu código queda portable entre Express y Fastify.
Cuándo migrar: si tu API es intensiva en JSON y estás peleando por latencia/throughput, Fastify es una de las palancas de mayor impacto y menor cambio de código. Si dependés fuerte de middleware de Express o de librerías atadas a su API, medí el costo de portar antes.
El event loop: no lo bloquees
Node ejecuta tu JavaScript en un único hilo. Ese hilo corre un bucle: toma un evento (una request entrante, un timer, la respuesta de una query), ejecuta el callback asociado hasta el final, y recién ahí pasa al siguiente. Si un callback tarda 200 ms haciendo trabajo síncrono, ninguna otra request avanza durante esos 200 ms. No es que “esa request es lenta”: todo el servidor se congela.
flowchart TD
R[Request entra] --> Q{Trabajo del handler}
Q -->|I/O: DB, HTTP, fs async| NB[Se delega a libuv / SO<br/>el hilo queda libre]
Q -->|CPU sincrono pesado| BL[El hilo se bloquea<br/>todas las requests esperan]
NB --> CB[Callback cuando la I/O termina]
BL --> DONE[Recien ahi vuelve el loop]
CB --> RES[Responde]
DONE --> RES
RES --> LOOP[Event loop toma el siguiente evento]
classDef bad fill:#7f1d1d,stroke:#ef4444,color:#fff;
classDef good fill:#14532d,stroke:#22c55e,color:#fff;
class BL bad;
class NB good;
La I/O asíncrona (queries, llamadas HTTP, fs.promises) no bloquea: Node la delega al pool de libuv o al sistema operativo y el hilo queda libre para atender otras requests. Lo que bloquea es el trabajo CPU-bound síncrono en el hilo principal.
Antipatrón: trabajo síncrono pesado en el handler
// ANTIPATRON: hashing/compresion/parsing sincrono en el hilo del request
import * as crypto from 'node:crypto';
@Post('report')
generate(@Body() dto: ReportDto): Report {
// pbkdf2Sync sobre un dataset grande: bloquea el event loop
const key = crypto.pbkdf2Sync(dto.seed, 'salt', 600_000, 64, 'sha512');
const rows = this.buildMillionRows(dto); // loop CPU-bound sincrono
const csv = rows.map(r => r.join(',')).join('\n'); // string gigante en memoria
return { key: key.toString('hex'), csv };
}
Cada request de este endpoint congela el servidor entero mientras corre. Dos soluciones según el caso:
1. Usar la variante asíncrona (I/O que ya existe async). Muchas APIs de Node tienen versión que corre en el threadpool de libuv:
import { pbkdf2 } from 'node:crypto';
import { promisify } from 'node:util';
const pbkdf2Async = promisify(pbkdf2);
const key = await pbkdf2Async(dto.seed, 'salt', 600_000, 64, 'sha512');
// libuv lo corre en su threadpool; el hilo de JS sigue libre
2. Offload a worker_threads (CPU-bound puro). Cuando el trabajo es cómputo JavaScript puro (parsear/transformar millones de filas, generar PDFs, procesamiento de imágenes), movelo a un worker:
// cpu.worker.ts
import { parentPort, workerData } from 'node:worker_threads';
const result = heavyTransform(workerData); // corre en OTRO hilo
parentPort!.postMessage(result);
// cpu.service.ts
import { Injectable } from '@nestjs/common';
import { Worker } from 'node:worker_threads';
@Injectable()
export class CpuService {
run<T>(data: unknown): Promise<T> {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./cpu.worker.js', import.meta.url), {
workerData: data,
});
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', (code) => {
if (code !== 0) reject(new Error(`Worker terminó con código ${code}`));
});
});
}
}
En producción, en vez de crear un worker por request (crearlos tiene costo), usás un pool de workers (por ejemplo la librería piscina), o directamente sacás el trabajo del ciclo request/response y lo mandás a una cola (BullMQ), tema del capítulo 9: async, colas y eventos.
Streaming en vez de cargar todo en memoria
Construir un string o array gigante en memoria (un CSV de un millón de filas, un export completo) tiene dos costos: la memoria en sí y el tiempo síncrono de armarlo. Si en vez de eso streameás, procesás de a chunks y liberás memoria a medida que va saliendo la respuesta:
// ANTIPATRON: todo en memoria
@Get('export')
async export(): Promise<string> {
const all = await this.repo.findEverything(); // millones de filas en RAM
return all.map(toCsvRow).join('\n'); // string gigante
}
// MEJOR: stream, memoria acotada
import { Controller, Get, StreamableFile, Header } from '@nestjs/common';
import { Readable } from 'node:stream';
@Get('export')
@Header('Content-Type', 'text/csv')
export(): StreamableFile {
const cursor = this.repo.cursor(); // async iterator del driver de la DB
const stream = Readable.from(this.toCsvChunks(cursor));
return new StreamableFile(stream);
}
private async *toCsvChunks(cursor: AsyncIterable<Row>): AsyncGenerator<string> {
for await (const row of cursor) {
yield toCsvRow(row) + '\n'; // una fila a la vez, memoria constante
}
}
StreamableFile funciona en Express y en Fastify, así que la respuesta es portable entre adapters.
Complejidad algorítmica en el backend
El event loop hace que la complejidad importe más que en un frontend: un O(n²) que en el navegador de un usuario “solo lo sufre ese usuario”, en el backend bloquea el hilo para todos. Los dos errores más comunes:
Loops anidados: O(n²) que debería ser O(n)
// ANTIPATRON: por cada orden, recorre TODOS los usuarios -> O(n * m)
function attachUsers(orders: Order[], users: User[]): OrderWithUser[] {
return orders.map((order) => ({
...order,
user: users.find((u) => u.id === order.userId), // find() es O(m)
}));
}
Con 10 000 órdenes y 10 000 usuarios eso son 100 millones de comparaciones, síncronas, en el hilo principal. El refactor: construir un índice Map una vez (O(m)) y hacer lookups O(1):
// MEJOR: index con Map -> O(n + m)
function attachUsers(orders: Order[], users: User[]): OrderWithUser[] {
const byId = new Map(users.map((u) => [u.id, u]));
return orders.map((order) => ({
...order,
user: byId.get(order.userId), // O(1)
}));
}
La misma idea aplica con Set para pertenencia (includes() sobre un array es O(n); set.has() es O(1)) y para deduplicar. Regla práctica: si tenés un .find(), .filter() o .includes() dentro de un .map() o de otro loop sobre colecciones grandes, casi seguro querés un Map/Set.
No traer datasets enteros a memoria
El O(n²) anterior tiene una causa más profunda: trajiste todos los usuarios y todas las órdenes a memoria para cruzarlos en JavaScript. Ese cruce es trabajo de la base de datos, que lo resuelve con índices y un JOIN:
// ANTIPATRON: dos SELECT * y cruce en Node
const orders = await this.orderRepo.findAll();
const users = await this.userRepo.findAll();
return attachUsers(orders, users);
// MEJOR: la DB hace el join, traés solo lo que necesitás
return this.orderRepo.findWithUser({ take: 50, skip: 0 });
Traer colecciones completas para filtrar/agregar/cruzar en el proceso es a la vez un problema de memoria y de CPU. El filtrado, la agregación y los joins son responsabilidad de la base. El caso más pernicioso de esto —hacer una query por cada elemento de una lista— es el problema N+1, que se cubre en profundidad en el capítulo 8: persistencia.
Caching: no recalcular ni re-consultar lo caro
Cachear es guardar el resultado de una operación cara (una query pesada, una llamada a un servicio externo, un cómputo) para servir las siguientes lecturas sin repetirla. NestJS integra cache-manager mediante @nestjs/cache-manager.
En NestJS 11 el paquete usa cache-manager v6, construido sobre Keyv. Cambió respecto de v4/v5: los TTL se expresan en milisegundos, los stores externos (Redis, etc.) se configuran como instancias de Keyv, y la API del manager es
get/set/del/mget/mset. Si venís de tutoriales viejos conttlen segundos ystore: redisStore, esa API cambió.
Caché en memoria vs Redis
- En memoria (default): rapidísima, cero infraestructura, pero por instancia. Si escalás horizontalmente a N pods, tenés N cachés desincronizadas y no compartís nada. Además compite por la RAM del proceso.
- Redis (u otro store de red): compartido entre todas las instancias, sobrevive a reinicios del proceso, permite invalidación centralizada. El costo es un salto de red por lectura y una dependencia operativa más.
Podés combinar ambos con multi-store (memoria como L1, Redis como L2):
import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import { Keyv } from 'keyv';
import KeyvRedis from '@keyv/redis';
import { KeyvCacheableMemory } from 'cacheable';
@Module({
imports: [
CacheModule.registerAsync({
isGlobal: true,
useFactory: async () => ({
stores: [
new Keyv({ store: new KeyvCacheableMemory({ ttl: 60_000, lruSize: 5000 }) }),
new KeyvRedis('redis://localhost:6379'),
],
}),
}),
],
})
export class AppModule {}
lruSize acota la caché en memoria a un máximo de entradas (política LRU): sin ese límite, una caché en memoria es una fuga de memoria esperando a ocurrir.
Cache-aside: el patrón por defecto
El patrón más común es cache-aside (lazy loading): el servicio consulta la caché; si está (hit), la devuelve; si no (miss), va a la fuente, guarda el resultado con un TTL y lo devuelve.
flowchart TD
A[Request llega al servicio] --> B{Esta en cache?}
B -->|Hit| C[Devolver valor cacheado]
B -->|Miss| D[Consultar la fuente<br/>DB / API externa]
D --> E[Guardar en cache con TTL]
E --> F[Devolver valor]
C --> G[Respuesta]
F --> G
classDef hit fill:#14532d,stroke:#22c55e,color:#fff;
classDef miss fill:#78350f,stroke:#f59e0b,color:#fff;
class C hit;
class D,E miss;
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
@Injectable()
export class ProductService {
constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly repo: ProductRepository,
) {}
async getById(id: string): Promise<Product> {
const key = `product:${id}`;
const cached = await this.cache.get<Product>(key);
if (cached) return cached; // hit
const product = await this.repo.findById(id); // miss -> fuente
await this.cache.set(key, product, 60_000); // TTL 60s (milisegundos)
return product;
}
}
El interceptor de caché
Para respuestas de endpoints GET que dependen solo de la URL, @nestjs/cache-manager trae CacheInterceptor, que automatiza el cache-aside sobre la respuesta HTTP. Los interceptores se cubren en el capítulo 6: ciclo de vida del request; acá lo usamos.
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { CacheInterceptor, CacheKey, CacheTTL } from '@nestjs/cache-manager';
@Controller('products')
@UseInterceptors(CacheInterceptor) // cachea las respuestas GET de este controller
export class ProductController {
@Get('featured')
@CacheKey('products:featured') // clave explícita
@CacheTTL(30_000) // 30s, override del TTL global
findFeatured() {
return this.service.findFeatured();
}
}
O global, para todos los GET, vía APP_INTERCEPTOR:
providers: [{ provide: APP_INTERCEPTOR, useClass: CacheInterceptor }],
Limitaciones a tener presentes: CacheInterceptor solo cachea GET, por defecto usa la URL como clave (no distingue por usuario ni headers, salvo que lo customices), y no sabe invalidar cuando cambian los datos. Para respuestas personalizadas por usuario o que dependen de más que la URL, el cache-aside manual da control fino.
Invalidación y TTL
La invalidación es la parte difícil del caching. Dos estrategias, combinables:
- TTL (expiración por tiempo): aceptás datos hasta
Tde viejos. Simple, robusto. Elegís el TTL según cuánta staleness tolera cada dato (un catálogo tolera minutos; un saldo, no). - Invalidación explícita (write-through/eviction): cuando escribís, borrás la clave afectada para que la próxima lectura recargue.
async update(id: string, dto: UpdateProductDto): Promise<Product> {
const updated = await this.repo.update(id, dto);
await this.cache.del(`product:${id}`); // invalidá la entrada
await this.cache.del('products:featured'); // y las derivadas que la incluyen
return updated;
}
El riesgo clásico es cachear con clave que no distingue lo que debería (devolver a un usuario el resultado cacheado de otro) o olvidar invalidar una vista derivada. Mantené las claves explícitas y documentá qué invalida a qué.
Compresión, límites de payload y keep-alive
Ajustes transversales que mueven la aguja sin tocar lógica:
- Compresión: comprimir la respuesta (gzip/brotli) reduce bytes en la red a cambio de CPU. Con Express usás el paquete
compression; con Fastify,@fastify/compress. Ojo: comprimir tiene costo de CPU en el hilo, así que en respuestas chicas o muy calientes puede no valer la pena; y no ayuda con contenido ya comprimido (imágenes, archivos binarios).
// Fastify
import compression from '@fastify/compress';
await app.register(compression, { encodings: ['gzip', 'deflate'] });
- Límites de payload: aceptar bodies arbitrariamente grandes es un riesgo de memoria y un vector de DoS. Limitá el tamaño del body (
bodyLimitenFastifyAdapter, o el límite del body parser en Express) al máximo razonable para tu API. - Keep-alive: reutilizar conexiones TCP evita el costo de handshake por request. Los clientes HTTP que hagas hacia afuera (a otros servicios) deberían usar un agente con keep-alive y pool de conexiones; abrir una conexión nueva por llamada agrega latencia y presiona los file descriptors.
Paginación y respuestas grandes
Un endpoint que devuelve “todos los registros” es una bomba de tiempo: crece con los datos hasta que un día tumba el proceso por memoria o timeout. Paginá siempre las colecciones.
// ANTIPATRON: sin límite
@Get()
findAll() {
return this.repo.findAll(); // 2 millones de filas el día que crezca
}
// MEJOR: límite acotado y validado
@Get()
findAll(@Query() { limit = 20, cursor }: ListQueryDto) {
const take = Math.min(limit, 100); // tope duro
return this.repo.paginate({ take, cursor });
}
Preferí paginación por cursor (keyset) sobre offset/limit para datasets grandes: OFFSET 100000 obliga a la base a recorrer y descartar 100 000 filas antes de devolver, mientras que un cursor sobre una columna indexada salta directo. Y para exports masivos que igual no caben en una página, usá streaming (visto arriba) en lugar de materializar todo.
Perfilado: medí antes de optimizar
Optimizar sin medir es adivinar. Herramientas para encontrar dónde se va el tiempo:
clinic.js: suite de perfilado para Node.clinic doctordiagnostica la salud general y detecta bloqueos del event loop (te dice si el problema es CPU, I/O o el loop);clinic flamegenera un flame graph para ver qué funciones consumen CPU;clinic bubbleprofvisualiza el flujo async.
clinic doctor --on-port 'autocannon localhost:3000' -- node dist/main.js
clinic flame -- node dist/main.js
node --prof: el profiler V8 incorporado. Genera unisolate-*.logque procesás connode --prof-processpara ver dónde se concentra el tiempo de CPU. Sin dependencias externas.--cpu-prof/--heap-prof: generan perfiles que abrís en el DevTools de Chrome (pestaña Performance/Memory).- Lag del event loop en producción:
perf_hooks.monitorEventLoopDelay()mide cuánto se retrasa el loop; exponés ese percentil como métrica. Un lag alto y sostenido es la señal directa de que algo está bloqueando el hilo.
import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
// periódicamente: h.mean, h.percentile(99) en nanosegundos -> exponer como métrica
Instrumentar esto de forma continua (latencias, lag del loop, hit ratio de caché, uso de memoria) es parte de la observabilidad, cubierta en el capítulo 12: observabilidad. El perfilado puntual encuentra el cuello de botella; las métricas continuas te avisan cuándo aparece uno nuevo.
Antipatrones de rendimiento
Un resumen de los errores que más frecuentemente vas a encontrar (y sus arreglos):
await en serie donde va Promise.all. Esperar operaciones independientes una tras otra suma sus latencias:
// ANTIPATRON: 3 llamadas independientes en serie -> t1 + t2 + t3
const user = await this.users.find(id);
const orders = await this.orders.forUser(id);
const prefs = await this.prefs.forUser(id);
// MEJOR: en paralelo -> max(t1, t2, t3)
const [user, orders, prefs] = await Promise.all([
this.users.find(id),
this.orders.forUser(id),
this.prefs.forUser(id),
]);
Ojo: solo paralelizá lo que es independiente. Si orders necesita user, no podés. Y si disparás cientos de promesas a la vez, saturás la base o el servicio destino: en esos casos acotá la concurrencia (p-limit, batches).
Procesar arrays enormes de forma síncrona. Un .map() o for sobre un millón de elementos con trabajo dentro bloquea el loop. Streameá, paginá el procesamiento o mandalo a un worker.
No cachear lo caro. Repetir en cada request una query pesada o una llamada externa que cambia poco. Cache-aside con TTL.
Cargar todo sin paginar. findAll() sin límite. Paginá con tope duro.
JSON.parse/JSON.stringify de payloads gigantes en el hilo principal. Ambas operaciones son síncronas y bloqueantes; parsear un JSON de decenas de MB congela el loop. Evitá recibir/emitir payloads así: paginá, streameá con un parser de streaming (stream-json), o mové el parseo a un worker.
SELECT * y cruces en memoria. Traer columnas y filas de más para filtrarlas/cruzarlas en Node. Que la base filtre, agregue y joinee; traé solo lo que devolvés.
Diagrama: decisión de rendimiento por tipo de trabajo
flowchart TD
T[Trabajo a ejecutar] --> K{Es I/O<br/>DB, HTTP, fs?}
K -->|Si| IO[Usar API async<br/>no bloquea el loop]
K -->|No, es CPU| C{Cuanto pesa?}
C -->|Liviano| INLINE[En el handler esta bien]
C -->|Pesado y puntual| W[worker_threads / piscina]
C -->|Pesado y diferible| Q[Cola BullMQ<br/>fuera del request]
IO --> CACHE{Resultado caro<br/>y reutilizable?}
CACHE -->|Si| CA[Cache-aside con TTL]
CACHE -->|No| DIRECT[Responder directo]
classDef good fill:#14532d,stroke:#22c55e,color:#fff;
class IO,W,Q,CA good;
Checklist
- Evaluaste Fastify como adapter si la API es intensiva en JSON y peleás por throughput.
- Ningún handler hace trabajo CPU-bound síncrono pesado en el hilo principal (hashing, parsing masivo, generación de archivos).
- El trabajo CPU-bound puro va a
worker_threads/piscinao a una cola, no al ciclo request/response. - Reemplazaste
.find()/.includes()dentro de loops por lookupsO(1)conMap/Set. - No traés datasets enteros a memoria para filtrar/cruzar/agregar en JavaScript; lo hace la base.
- Cacheás las lecturas caras y reutilizables con cache-aside y TTL (memoria o Redis según escales).
- Tenés una estrategia de invalidación explícita para los datos que no toleran staleness.
- Toda colección está paginada con un tope duro; los exports masivos se streamean.
- Operaciones independientes usan
Promise.all(con concurrencia acotada si son muchas). - Configuraste compresión, límite de payload y keep-alive en clientes salientes.
- Perfilaste con
clinic.js/--profantes de optimizar y exponés el lag del event loop como métrica.
Ya sabés no bloquear el hilo, no traer de más y cachear lo caro. La fuente número uno de lentitud real en un backend, sin embargo, está en cómo hablás con la base de datos: queries N+1, transacciones mal manejadas y falta de índices. Eso es lo que ataca el siguiente capítulo: Persistencia →