Rendimiento en NestJS

Por: Artiko
nestjsbuenas-practicasrendimientocachingevent-loopfastifynodejs

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á:

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 con ttl en segundos y store: redisStore, esa API cambió.

Caché en memoria vs Redis

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:

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:

// Fastify
import compression from '@fastify/compress';
await app.register(compression, { encodings: ['gzip', 'deflate'] });

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 doctor --on-port 'autocannon localhost:3000' -- node dist/main.js
clinic flame -- node dist/main.js
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

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 →