Parte VIII · Ampliaciones

28. Node.js en producción: bucle de eventos, memoria, streams y perfilado

NestJS es una capa de organización sobre un proceso de Node.js. Cuando ese proceso se degrada bajo carga, ninguna abstracción del framework te va a salvar: hay que bajar al motor. Este capítulo explica cómo se ejecuta realmente tu código, por qué una función aparentemente inocente puede paralizar el servidor entero, cómo se gestiona la memoria, cuándo hacen falta hilos de verdad, y qué herramientas usar para diagnosticar en lugar de adivinar.

AVANZADO Tiempo de lectura: ~80 min Prerrequisitos: capítulos 1, 9, 13 y 21

28.1 Qué vas a poder hacer al terminar

  • Describir con precisión las fases del bucle de eventos, qué se ejecuta en cada una y en qué orden, y explicar la diferencia entre process.nextTick, las microtareas de las promesas, setTimeout y setImmediate.
  • Reconocer las operaciones que bloquean el hilo principal y medir el retardo del bucle de eventos en producción, que es la métrica que de verdad indica si tu proceso está sano.
  • Decidir con criterio entre no hacer nada, un hilo de trabajo, un proceso hijo, varios procesos en clúster o una cola externa, según la naturaleza de la tarea.
  • Usar flujos con contrapresión correcta y explicar por qué acumular una respuesta grande en memoria acaba matando el proceso.
  • Entender cómo funciona el montón de V8, sus generaciones y su recolector, y diagnosticar una fuga de memoria con instantáneas comparadas en lugar de reiniciar el proceso cada noche.
  • Perfilar consumo de CPU y obtener un gráfico de llamas que señale exactamente qué función se está comiendo el tiempo.
  • Configurar un proceso de Node.js para producción: límites de memoria, apagado ordenado, tiempos de espera, mantenimiento de conexiones y comprobaciones de salud.
  • Dimensionar el grupo de conexiones a la base de datos con un razonamiento, y no con el número que venía por defecto.
  • Explicar en una entrevista por qué Node.js es «asíncrono y de un solo hilo» sin decir ninguna de las medias verdades habituales.

28.2 Cómo se ejecuta realmente tu código

La frase «Node.js es de un solo hilo» se repite tanto y se entiende tan poco que conviene desmontarla desde el principio. Un proceso de Node.js tiene varios hilos. Lo que tiene uno solo es el hilo que ejecuta tu JavaScript. Alrededor de él, la biblioteca libuv mantiene un grupo de hilos para operaciones de sistema de ficheros y criptografía, y el sistema operativo se encarga por su cuenta de la entrada y salida de red mediante mecanismos de notificación que no consumen un hilo por conexión.

Esa distinción no es una curiosidad: es la que explica por qué diez mil conexiones simultáneas no son un problema y por qué cinco llamadas a fs.readFileSync sí lo son.

  ┌─────────────────────────────────────────────────────────────┐
  │                     PROCESO DE NODE.JS                      │
  │                                                             │
  │   ┌───────────────────────────────────────────────────┐     │
  │   │  HILO PRINCIPAL                                   │     │
  │   │  ┌─────────────┐      ┌────────────────────────┐  │     │
  │   │  │ V8          │      │  libuv                 │  │     │
  │   │  │ (ejecuta tu │◄────►│  (bucle de eventos)    │  │     │
  │   │  │  JavaScript)│      │                        │  │     │
  │   │  └─────────────┘      └───────────┬────────────┘  │     │
  │   └───────────────────────────────────┼───────────────┘     │
  │                                       │                     │
  │        ┌──────────────────────────────┼──────────────┐      │
  │        ▼                              ▼              ▼      │
  │  ┌───────────┐              ┌──────────────┐  ┌───────────┐ │
  │  │ Thread    │              │ epoll/kqueue │  │ Worker    │ │
  │  │ pool      │              │ /IOCP        │  │ threads   │ │
  │  │ (4 por    │              │              │  │ (los que  │ │
  │  │  defecto) │              │ La RED no    │  │  crees tú)│ │
  │  │           │              │ consume      │  │           │ │
  │  │ fs, dns,  │              │ hilos: el SO │  │ Cada uno  │ │
  │  │ zlib,     │              │ avisa cuando │  │ con su    │ │
  │  │ crypto    │              │ hay datos    │  │ propio V8 │ │
  │  └───────────┘              └──────────────┘  └───────────┘ │
  └─────────────────────────────────────────────────────────────┘
Analogía: el camarero y la cocina

Un restaurante con un solo camarero muy rápido. El camarero toma la comanda, la pasa a cocina y, en lugar de quedarse esperando, atiende la mesa siguiente. Cuando un plato está listo, la cocina lo avisa y él lo lleva. Un camarero así atiende cincuenta mesas sin despeinarse, porque nunca se queda parado esperando. El desastre llega el día que alguien le pide que pele él mismo cinco kilos de patatas en el comedor: mientras pela, nadie toma comandas, nadie recibe su plato y las cincuenta mesas esperan. El grupo de hilos de libuv son los pinches que pueden pelar en la cocina; los hilos de trabajo son camareros adicionales contratados para tareas concretas; y el clúster es abrir cinco comedores idénticos. Todo el capítulo consiste en identificar las patatas y sacarlas del comedor.

28.2.1 Las fases del bucle de eventos

El bucle de eventos no es una cola única: es un ciclo de seis fases, cada una con su propia cola de callbacks. Conocer el orden explica comportamientos que de otro modo parecen caprichosos.

   ┌─────────────────────────────────────────────┐
   │                                             │
   │   ┌──────────────────────────┐              │
   └──►│         timers           │  callbacks de setTimeout
       │                          │  y setInterval vencidos
       └────────────┬─────────────┘
                    ▼
       ┌──────────────────────────┐
       │   pending callbacks      │  algunos errores de TCP
       └────────────┬─────────────┘  aplazados del ciclo anterior
                    ▼
       ┌──────────────────────────┐
       │   idle, prepare          │  uso interno de libuv
       └────────────┬─────────────┘
                    ▼
       ┌──────────────────────────┐      ┌─────────────────┐
       │         poll             │◄────►│ Conexiones      │
       │  Aquí se pasa la mayor   │      │ entrantes,      │
       │  parte del tiempo.       │      │ datos leídos,   │
       │  Si no hay nada más que  │      │ ficheros        │
       │  hacer, BLOQUEA aquí     │      │ terminados      │
       │  esperando E/S.          │      └─────────────────┘
       └────────────┬─────────────┘
                    ▼
       ┌──────────────────────────┐
       │        check             │  callbacks de setImmediate
       └────────────┬─────────────┘
                    ▼
       ┌──────────────────────────┐
       │    close callbacks       │  socket.on('close'), etc.
       └────────────┬─────────────┘
                    │
                    └───── vuelta al principio ─────►

   ENTRE CADA FASE, y también entre cada callback individual, se vacían
   POR COMPLETO estas dos colas, en este orden:

       1. process.nextTick()   ← máxima prioridad
       2. microtareas          ← promesas: .then, await, queueMicrotask
orden.js — el ejercicio clásico de entrevista
console.log('1. síncrono');

setTimeout(() => console.log('6. timers'), 0);
setImmediate(() => console.log('7. check'));

Promise.resolve().then(() => console.log('4. microtarea'));
process.nextTick(() => console.log('3. nextTick'));

queueMicrotask(() => console.log('5. microtarea 2'));

console.log('2. síncrono');

// Salida:
// 1. síncrono
// 2. síncrono
// 3. nextTick        ← toda la cola de nextTick, antes que las promesas
// 4. microtarea      ← luego las microtareas, en orden de encolado
// 5. microtarea 2
// 6. timers          ← ya en la siguiente iteración del bucle
// 7. check
El matiz que hace fallar a los candidatos

El orden entre setTimeout(fn, 0) y setImmediate(fn) no está garantizado cuando ambos se llaman desde el módulo principal. Depende de cuánto tarde el proceso en arrancar el bucle: si en ese momento ya ha pasado un milisegundo, el temporizador vence y gana; si no, gana setImmediate. Ejecuta el ejemplo varias veces y verás cómo cambia. En cambio, dentro de un callback de entrada y salida el orden sí es determinista: setImmediate siempre va primero, porque la fase check viene inmediatamente después de poll, mientras que los temporizadores tendrían que esperar a la vuelta completa del ciclo. Si en una entrevista te preguntan cuál se ejecuta antes, la respuesta correcta empieza por «depende de desde dónde se llamen».

process.nextTick puede matar el bucle de eventos

La cola de nextTick se vacía entera antes de continuar, y si un callback de esa cola encola otro nextTick, se procesa también en el mismo vaciado. Una recursión con nextTick produce inanición del bucle: el proceso está al cien por cien de CPU, no atiende ni una sola petición nueva y ni siquiera responde a la comprobación de salud, pero tampoco se cuelga formalmente. Es uno de los fallos más desconcertantes que se pueden provocar. La misma recursión con setImmediate es inofensiva, porque cede el turno al bucle en cada vuelta. La regla práctica: usa nextTick solo si sabes exactamente por qué lo necesitas, y en caso de duda usa setImmediate o queueMicrotask.

inanicion.jsMATA EL PROCESO
let n = 0;
function procesar() {
  if (n++ < 1e9) process.nextTick(procesar);
}
procesar();

setTimeout(() => {
  console.log('esto NO se imprime nunca');
}, 100);

// El bucle jamás sale de vaciar la cola de
// nextTick. El servidor deja de responder.
cediendo.jsCEDE EL TURNO
let n = 0;
function procesar() {
  if (n++ < 1e9) setImmediate(procesar);
}
procesar();

setTimeout(() => {
  console.log('esto SÍ se imprime');
}, 100);

// setImmediate encola en la fase check, así
// que el bucle completa la vuelta y atiende
// temporizadores y E/S entre iteraciones.

28.2.2 El grupo de hilos y su tamaño

Cuatro tipos de operación no se pueden resolver con notificaciones del sistema operativo y por eso libuv las delega en un grupo de hilos: el sistema de ficheros, la resolución de nombres con dns.lookup, la compresión con zlib y algunas operaciones criptográficas como pbkdf2, scrypt y la generación de claves. Ese grupo tiene cuatro hilos por defecto, y ese número es una de las causas de degradación más silenciosas que existen.

demostracion-pool.js
const crypto = require('node:crypto');
const inicio = Date.now();

// Cinco operaciones criptográficas costosas lanzadas a la vez.
for (let i = 0; i < 5; i++) {
  crypto.pbkdf2('clave', 'sal', 200_000, 64, 'sha512', () => {
    console.log(`${i}: ${Date.now() - inicio} ms`);
  });
}

// Salida típica en una máquina de 4 núcleos:
//   0: 410 ms
//   1: 415 ms
//   2: 418 ms
//   3: 420 ms
//   4: 830 ms   ← ¡el doble! Esperó a que se liberara un hilo.
//
// Con UV_THREADPOOL_SIZE=8 las cinco terminan a la vez.
// Con 100 peticiones simultáneas de login usando bcrypt, este efecto
// convierte una latencia de 400 ms en una de 10 segundos.
Cómo dimensionar UV_THREADPOOL_SIZE

La variable se lee una sola vez, al arrancar el proceso, y debe estar puesta antes de que se cargue ningún módulo que use el grupo. Ponerla en el código con process.env.UV_THREADPOOL_SIZE = '8' después de los import no tiene ningún efecto, y es un error muy habitual. Sobre el valor: subirlo por encima del número de núcleos disponibles solo ayuda si las operaciones son de espera y no de cálculo. El hasheo de contraseñas es cálculo puro, así que más hilos que núcleos solo reparte la misma CPU entre más competidores. Si tu cuello de botella es el hasheo en el inicio de sesión, la solución no es tocar esta variable sino sacar el hasheo del proceso, con una cola o con un servicio dedicado.

28.3 Bloquear el bucle: el pecado capital

Cualquier código JavaScript síncrono que tarde en ejecutarse impide que el bucle avance. Durante ese tiempo el proceso no acepta conexiones, no lee sockets, no dispara temporizadores y no responde a la comprobación de salud. Con una sola instancia, cien milisegundos de bloqueo por petición y cien peticiones por segundo, el servidor está permanentemente saturado aunque la CPU parezca ir al treinta por ciento en las gráficas.

Operación aparentemente inofensivaPor qué bloqueaAlternativa
JSON.parse o JSON.stringify de un objeto muy grandeEs código C++ síncrono. Un JSON de 50 MB puede tardar cientos de milisegundos.Paginar la respuesta, transmitirla con un analizador incremental o mover la serialización a un hilo de trabajo.
fs.readFileSyncEspera al disco sin ceder el turno.fs.promises.readFile, o un flujo si el fichero es grande.
bcrypt.hashSyncCálculo intensivo por diseño: su objetivo es ser lento.La versión asíncrona, que usa el grupo de hilos. Y controlar la concurrencia de los inicios de sesión.
Una expresión regular con retroceso catastróficoEl motor explora exponencialmente. Es la denegación de servicio por expresión regular.Simplificar el patrón, limitar la longitud de la entrada, o usar una biblioteca con garantías de tiempo lineal.
array.sort sobre cientos de miles de elementosEs síncrono y no cede.Ordenar en la base de datos con un ORDER BY, que para eso está y además tiene el índice.
Un bucle sobre el resultado completo de una consulta sin límiteEl problema no es el bucle, es traer un millón de filas.Paginación por cursor, o procesamiento por lotes.
Generación de PDF o de Excel en memoriaCálculo puro en el hilo principal.Cola con BullMQ y descarga posterior, como en el capítulo 11.
crypto.randomBytes síncrono con muchos bytesPuede agotar la entropía y bloquear.La versión con callback.

28.3.1 Medir el retardo del bucle

La métrica clave no es la CPU ni la memoria: es cuánto se retrasa el bucle de eventos respecto a lo que debería tardar. Si programas algo para dentro de veinte milisegundos y se ejecuta a los ciento ochenta, tienes ciento sesenta milisegundos de retardo y todas tus peticiones lo están sufriendo. Node.js incluye un histograma preciso para medirlo.

src/observabilidad/bucle.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';

@Injectable()
export class BucleService implements OnModuleInit, OnModuleDestroy {
  private readonly log = new Logger(BucleService.name);
  // resolution: cada cuántos ms se toma una muestra. 20 ms es un
  // buen compromiso entre precisión y coste.
  private readonly histograma = monitorEventLoopDelay({ resolution: 20 });
  private temporizador?: NodeJS.Timeout;

  onModuleInit() {
    this.histograma.enable();

    this.temporizador = setInterval(() => {
      const h = this.histograma;
      const metricas = {
        // El histograma trabaja en nanosegundos.
        p50: +(h.percentile(50) / 1e6).toFixed(1),
        p99: +(h.percentile(99) / 1e6).toFixed(1),
        max: +(h.max / 1e6).toFixed(1),
      };

      // El p99 es la señal útil. La media esconde justo lo que
      // buscas: un bloqueo de 2 s cada minuto no mueve la media.
      if (metricas.p99 > 100) {
        this.log.warn(`Retardo del bucle alto: ${JSON.stringify(metricas)}`);
      }
      this.publicarEnPrometheus(metricas);
      h.reset();
    }, 10_000);

    // unref() evita que este intervalo mantenga vivo el proceso
    // cuando todo lo demás ha terminado y queremos apagar.
    this.temporizador.unref();
  }

  onModuleDestroy() {
    clearInterval(this.temporizador);
    this.histograma.disable();
  }

  private publicarEnPrometheus(m: Record<string, number>) { /* ... */ }
}
Retardo del bucle (p99)InterpretaciónActuación
Menos de 10 msProceso sano.Ninguna.
10 a 50 msCarga apreciable pero manejable.Vigilar la tendencia.
50 a 200 msHay trabajo síncrono significativo. La latencia de tus rutas ya lo está notando.Perfilar y localizar el bloqueo.
Más de 200 msGrave. Las comprobaciones de salud empezarán a fallar y el orquestador reiniciará el contenedor, agravando el problema al redistribuir la carga sobre las instancias restantes.Alertar y actuar de inmediato.
Rechazar antes que morir: descarga de carga

Existe un patrón muy efectivo y poco conocido que consiste en devolver un 503 cuando el retardo del bucle supera un umbral. Parece contraintuitivo rechazar trabajo, pero la alternativa es peor: un proceso saturado acepta peticiones que no puede atender, todas acumulan latencia, los clientes agotan su tiempo de espera y reintentan, y la carga sube todavía más hasta el colapso total. Rechazar rápido el diez por ciento de las peticiones mantiene el noventa por ciento restante con latencia normal. En el ecosistema de Node.js, el paquete toobusy-js implementa esta idea y se integra en un middleware de Nest en diez líneas. Combínalo con un Retry-After como el del capítulo 25.

28.4 Cuando hace falta paralelismo de verdad

Ante una tarea que consume CPU hay cinco respuestas posibles, ordenadas de menor a mayor coste. La disciplina profesional consiste en no saltar a la quinta sin haber descartado las cuatro anteriores.

OpciónCuándoCosteTrampa
No hacer nadaLa tarea tarda menos de 10 ms y se ejecuta pocas veces por segundo.CeroMedir de verdad antes de concluir que es despreciable.
Trocear con setImmediateUn bucle largo sobre datos que se puede partir en lotes.Muy bajoNo reduce el tiempo total, solo evita el bloqueo. La latencia de esa petición empeora.
Delegar a la base de datosAgregaciones, ordenaciones, filtros, cálculos sobre conjuntos.BajoLa mejor opción y la más ignorada. Un GROUP BY con índice gana siempre a un reduce en Node.
Hilo de trabajoCálculo intensivo, con datos que caben en un mensaje, y latencia que importa.MedioCada hilo levanta una instancia de V8, así que consume decenas de megabytes. No crees uno por petición: usa un grupo.
Cola externaLa tarea tarda segundos o minutos, o debe sobrevivir a un reinicio.AltoNecesitas Redis, trabajadores, reintentos y observabilidad. Es lo del capítulo 11.

28.4.1 Hilos de trabajo con un grupo reutilizable

src/informes/informe.worker.ts
import { parentPort, workerData } from 'node:worker_threads';

// Este fichero se ejecuta en un HILO APARTE, con su propia instancia
// de V8, su propio montón y su propio bucle de eventos. No comparte
// variables con el hilo principal ni tiene acceso a los servicios de
// Nest: hay que pasarle todo lo que necesite.
function calcular(filas: FilaTarea[]) {
  // Cálculo pesado: agregaciones, percentiles, tendencias...
  return filas.reduce(/* ... */);
}

parentPort!.on('message', (mensaje: { id: string; filas: FilaTarea[] }) => {
  try {
    parentPort!.postMessage({ id: mensaje.id, resultado: calcular(mensaje.filas) });
  } catch (e) {
    parentPort!.postMessage({ id: mensaje.id, error: (e as Error).message });
  }
});
src/informes/grupo-hilos.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { Worker } from 'node:worker_threads';
import { cpus } from 'node:os';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';

interface Pendiente {
  resolver: (v: unknown) => void;
  rechazar: (e: Error) => void;
  reloj: NodeJS.Timeout;
}

@Injectable()
export class GrupoHilosService implements OnModuleInit, OnModuleDestroy {
  private hilos: Worker[] = [];
  private libres: Worker[] = [];
  private readonly espera: Array<(w: Worker) => void> = [];
  private readonly pendientes = new Map<string, Pendiente>();

  onModuleInit() {
    // Regla de dimensionado: núcleos menos uno, dejando el principal
    // para atender la red. Nunca más hilos que núcleos: competirían
    // por la misma CPU y el cambio de contexto empeoraría todo.
    const n = Math.max(1, cpus().length - 1);

    for (let i = 0; i < n; i++) {
      const w = new Worker(join(__dirname, 'informe.worker.js'));

      w.on('message', (m: { id: string; resultado?: unknown; error?: string }) => {
        const p = this.pendientes.get(m.id);
        if (!p) return;
        clearTimeout(p.reloj);
        this.pendientes.delete(m.id);
        m.error ? p.rechazar(new Error(m.error)) : p.resolver(m.resultado);
        this.devolver(w);
      });

      // Un hilo puede morir por un error no capturado o por quedarse
      // sin memoria. Hay que reponerlo o el grupo se vacía en silencio
      // y las peticiones se quedan esperando para siempre.
      w.on('error', () => this.reponer(w));
      w.on('exit', (codigo) => { if (codigo !== 0) this.reponer(w); });

      this.hilos.push(w);
      this.libres.push(w);
    }
  }

  async ejecutar<T>(filas: unknown[], msTope = 30_000): Promise<T> {
    const hilo = await this.tomarLibre();
    const id = randomUUID();

    return new Promise<T>((resolver, rechazar) => {
      // Un tope de tiempo es imprescindible: si el hilo entra en un
      // bucle infinito, sin él la promesa nunca se resolvería y la
      // petición HTTP quedaría colgada indefinidamente.
      const reloj = setTimeout(() => {
        this.pendientes.delete(id);
        void hilo.terminate();   // matar y reponer
        this.reponer(hilo);
        rechazar(new Error('El cálculo superó el tiempo máximo'));
      }, msTope);

      this.pendientes.set(id, { resolver: resolver as any, rechazar, reloj });
      hilo.postMessage({ id, filas });
    });
  }

  private tomarLibre(): Promise<Worker> {
    const libre = this.libres.pop();
    if (libre) return Promise.resolve(libre);
    // No hay hilos libres: la petición espera en cola en lugar de
    // crear un hilo nuevo. Crear hilos bajo demanda con carga alta
    // es la forma más rápida de quedarse sin memoria.
    return new Promise((r) => this.espera.push(r));
  }

  private devolver(w: Worker) {
    const siguiente = this.espera.shift();
    siguiente ? siguiente(w) : this.libres.push(w);
  }

  private reponer(muerto: Worker) { /* crea uno nuevo y sustituye */ }

  async onModuleDestroy() {
    await Promise.all(this.hilos.map((w) => w.terminate()));
  }
}
El coste real de pasar datos a un hilo

Los mensajes entre hilos se transfieren con el algoritmo de clonado estructurado, que copia los datos. Enviar un array de cien mil objetos implica serializarlo, copiarlo y deserializarlo, y ese trabajo ocurre en el hilo principal, que es justo el que querías descargar. Hay casos en los que el coste del traspaso supera al del cálculo, y entonces el hilo empeora las cosas. Hay dos salidas: transferir un ArrayBuffer en lugar de copiarlo, indicándolo en la lista de transferencia, con lo que el buffer cambia de dueño con coste cero pero deja de ser accesible desde el origen; o usar SharedArrayBuffer para memoria realmente compartida, con la complejidad de sincronización que eso conlleva. Antes de introducir hilos, mide siempre el coste del traspaso.

28.4.2 Clúster, y por qué probablemente no lo necesitas

El módulo cluster arranca varios procesos idénticos que comparten el puerto de escucha, repartiendo las conexiones entre ellos. Multiplica el aprovechamiento de una máquina con varios núcleos, pero en una arquitectura moderna esa función suele estar cubierta un nivel más arriba.

Usa clúster si

  • Despliegas en una máquina virtual concreta, sin orquestador.
  • La máquina tiene varios núcleos y una sola instancia los desaprovecha.
  • Quieres reinicios sin corte, levantando el proceso nuevo antes de bajar el viejo.

No lo uses si

  • Despliegas en Kubernetes o similar: el orquestador ya escala replicando contenedores, y además reparte entre nodos, cosa que el clúster no puede.
  • Cada contenedor tiene un núcleo asignado: no hay nada que repartir.
  • Tu aplicación guarda estado en memoria, porque cada proceso tendría el suyo y verías comportamientos incoherentes según a qué trabajador caiga la petición. Es una causa muy frecuente de errores intermitentes con las sesiones y con las cachés en memoria.
El error que aparece el día que añades el segundo proceso

Toda caché en memoria, todo contador, todo Map de estado y todo temporizador programado dejan de ser globales en cuanto hay más de un proceso. Una tarea programada con @Cron se ejecutará una vez por proceso, así que el informe nocturno se enviará cuatro veces. Un limitador de peticiones en memoria permitirá cuatro veces el límite. Una caché en memoria tendrá cuatro copias distintas y desincronizadas. La solución para las tareas programadas es un bloqueo distribuido en Redis o en la base de datos; para las cachés y los limitadores, Redis compartido. Y esto no es exclusivo del módulo cluster: pasa exactamente igual con dos réplicas en Kubernetes, así que conviene diseñarlo desde el primer día aunque hoy solo tengas una.

28.5 Streams: no acumules lo que puedes fluir

Un stream es una abstracción sobre datos que llegan a trozos. En lugar de esperar a tener el fichero entero en memoria para procesarlo, lo vas consumiendo conforme llega. En un servidor de producción esto no es una optimización cosmética: es la diferencia entre exportar un informe de cien mil tareas con 50 MB de RAM o tumbar el proceso con un Out of memory.

  SIN STREAM (malo)                    CON STREAM (bueno)

  [BD] ──► buffer enorme ──► [HTTP]    [BD] ──► trozo ──► [HTTP]
            ▲                                    │
            │ todo en RAM                        ▼
            │ a la vez                    el siguiente trozo
                                          (contrapresión si
                                           el cliente es lento)
informes.controller.tsINCORRECTO
@Get('export.csv')
async exportar() {
  // find() carga TODAS las filas en memoria.
  const tareas = await this.em.find(Tarea, {});
  const csv = tareas.map((t) =>
    `${t.id},${escape(t.titulo)},${t.estado}`
  ).join('\n');
  return csv; // String enorme en el montón
}
// Con 500.000 tareas esto es un OOM garantizado
// en un contenedor de 512 MB.
informes.controller.tsCORRECTO
@Get('export.csv')
async exportar(@Res({ passthrough: false }) res: Response) {
  res.setHeader('Content-Type', 'text/csv; charset=utf-8');
  res.setHeader(
    'Content-Disposition',
    'attachment; filename="tareas.csv"',
  );
  res.write('id,titulo,estado\n');

  // Cursor del lado del servidor: una fila cada vez.
  for await (const t of this.em.createQueryBuilder(Tarea)
    .select(['id', 'titulo', 'estado'])
    .orderBy({ id: 'ASC' })
    .getResultStream()) {
    const linea = `${t.id},${csvEscape(t.titulo)},${t.estado}\n`;
    // write() puede devolver false: el búfer de salida está lleno.
    // Hay que esperar a 'drain' o saturarás la memoria igual.
    if (!res.write(linea)) {
      await once(res, 'drain');
    }
  }
  res.end();
}
La contrapresión no es opcional

Si produces más rápido de lo que el consumidor puede absorber y no respetas la contrapresión, el stream acumula trozos en memoria y acabas en el mismo Out of memory que querías evitar. En streams de Node, writable.write() devolviendo false significa «para hasta que emita drain». Con la API de pipelines modernos, pipeline() o Readable.toWeb() gestionan esto por ti; a mano, olvidas el drain una sola vez y el fallo aparece solo con clientes lentos o ficheros grandes.

28.5.1 pipeline y composición

src/adjuntos/adjuntos.service.ts
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';

async function comprimirAdjunto(origen: string, destino: string) {
  // pipeline propaga errores, destruye los streams si uno falla
  // y espera a que todo termine. pipe() clásico NO hace nada de eso.
  await pipeline(
    createReadStream(origen),
    createGzip({ level: 6 }),
    createWriteStream(destino),
  );
}
APIQué hace bienQué hace mal / riesgo
a.pipe(b)Corta y funciona en ejemplosNo propaga errores; no limpia; no espera el final. Evítalo en código nuevo.
pipeline(a, b, c)Errores, limpieza, promesaNada grave. Preferible.
Streams web (ReadableStream)Misma abstracción en navegador y Node modernoInteroperar con streams clásicos exige adaptadores.
Acumular en Buffer.concatCómodo para ficheros pequeñosMata el proceso con cargas grandes (subidas de adjuntos sin límite).
Subidas de ficheros en Nest

Si usas FileInterceptor de Multer con almacenamiento en memoria (memoryStorage), cada fichero entero vive en el montón hasta que termina la petición. Para TaskFlow, guarda en disco o en un almacén de objetos (S3) con streaming y limita tamaño y tipo MIME en el pipe de validación. Un límite de 10 MB por adjunto y un rechazo 413 claro son mejores que un contenedor que se reinicia «a veces».

28.6 Memoria: el montón de V8 y las fugas

V8 divide la memoria del proceso en varias regiones. La que más te importa es el montón (heap), donde viven los objetos de JavaScript. El recolector de basura libera lo que ya no es alcanzable; lo que permanece referenciado desde algún sitio «vivo» (una variable global, una caché, un closure, un listener) no se libera nunca. Eso es una fuga.

  ┌─────────────────────────────────────────────┐
  │  PROCESO NODE                               │
  │  ┌──────────────┐  ┌─────────────────────┐  │
  │  │ Stack        │  │ Montón (heap)       │  │
  │  │ (marcos de   │  │  Nueva generación   │  │
  │  │  llamada)    │  │  (objetos jóvenes,  │  │
  │  └──────────────┘  │   GC frecuente)     │  │
  │                    │  Vieja generación   │  │
  │  ┌──────────────┐  │  (sobreviven más,   │  │
  │  │ C++ / Buffers│  │   GC más caro)      │  │
  │  │ fuera del    │  └─────────────────────┘  │
  │  │ heap de JS   │                           │
  │  │ (ojo: también│  Límite típico: --max-old-│
  │  │  cuentan)    │  space-size (por defecto  │
  │  └──────────────┘  ~2–4 GB según máquina)   │
  └─────────────────────────────────────────────┘
terminal — inspeccionar memoria en caliente
# RSS = memoria residente del proceso (lo que ve el SO)
node -e "setInterval(() => console.log(process.memoryUsage()), 2000)"

# heapUsed / heapTotal: montón de V8
# external: Buffers y objetos C++
# arrayBuffers: ArrayBuffer / SharedArrayBuffer

28.6.1 Fugas típicas en NestJS / TaskFlow

PatrónPor qué fugaArreglo
Map o array global que crece con cada peticiónNunca se borra la entradaTTL, tamaño máximo (LRU), o Redis con caducidad
Listeners sin off / removeListenerEl emisor retiene el callback y su closureQuitarlos en onModuleDestroy; preferir AbortSignal
Suscripciones RxJS sin completarEl observable retiene el observerEn Nest suele importar menos; en Angular, takeUntilDestroyed
Caché de EntityManager / request scope mal usadoReferencias a entidades de peticiones viejasRequestContext + em.fork() por petición (cap. 14)
Logs que concatenan cuerpos enterosStrings enormes retenidos en buffers de loggingTruncar, muestrear, no loguear payloads
Closures en temporizadoressetInterval captura el servicio enteroclearInterval en destroy; unref() si aplica
cache.service.tsINCORRECTO
const cache = new Map<string, Tarea>();

@Injectable()
export class CacheTareas {
  get(id: string) { return cache.get(id); }
  set(id: string, t: Tarea) { cache.set(id, t); }
  // Sin evicción: en un mes el Map tiene
  // todas las tareas que existieron.
}
cache.service.tsCORRECTO
import { LRUCache } from 'lru-cache';

@Injectable()
export class CacheTareas {
  private readonly cache = new LRUCache<string, TareaDto>({
    max: 5_000,
    ttl: 60_000, // 1 minuto
  });

  get(id: string) { return this.cache.get(id); }
  set(id: string, t: TareaDto) { this.cache.set(id, t); }
}

28.6.2 Diagnosticar con instantáneas del montón

src/debug/heap.controller.ts — solo en entornos no productivos
import { Controller, Post, UseGuards } from '@nestjs/common';
import { writeHeapSnapshot } from 'node:v8';
import { AdminGuard } from '../seguridad/admin.guard';

@Controller('interno/debug')
@UseGuards(AdminGuard)
export class HeapController {
  @Post('heap-snapshot')
  snapshot() {
    // Genera un .heapsnapshot que abres en Chrome DevTools
    // → Memory → Load. Compara dos capturas: "antes" y "después"
    // de reproducir la fuga. Busca objetos que CRECEN y retienen.
    const ruta = writeHeapSnapshot();
    return { ruta };
  }
}
Instantáneas en producción

writeHeapSnapshot() es síncrono y puede bloquear el bucle decenas o cientos de milisegundos (o más) mientras serializa el montón. No lo expongas a Internet. Si lo necesitas en producción, hazlo en una réplica aislada, con autenticación fuerte, y avisa al equipo: durante la captura la latencia se dispara.

Analogía: el trastero

El recolector de basura es el servicio de limpieza del edificio: tira lo que nadie reclama. Una fuga es dejar la llave del trastero enganchada a un llavero que nunca sueltas: el trastero «sigue en uso» aunque no entres nunca. En las instantáneas buscas el llavero (el retener) no la basura suelta.

28.7 Perfilado de CPU y gráficos de llamas

Cuando el retardo del bucle es alto y la CPU está alta, necesitas saber qué función consume el tiempo. Adivinar («seguro que es la base de datos») falla la mitad de las veces: a menudo es serialización JSON, una regex, un mapper o bcrypt mal dimensionado.

terminal — perfilar 30 segundos
# Opción 1: inspector (Chrome DevTools → Performance)
node --inspect=0.0.0.0:9229 dist/main.js
# Abre chrome://inspect → Profile → Start

# Opción 2: perfil de CPU a fichero (0 = infinito hasta SIGINT)
node --cpu-prof --cpu-prof-dir=./perfiles dist/main.js
# Genera un .cpuprofile; ábrelo en DevTools o en speedscope.app

# Opción 3: clinic.js (muy didáctico)
npx clinic flame -- node dist/main.js
cómo leer un flamegraph (idea)
/*
  Eje X = tiempo (o muestras). Eje Y = pila de llamadas.
  Las barras ANCHAS arriba del todo son el coste agregado.
  Baja hasta la barra ancha más profunda que sea TU código:
  ahí está el sospechoso.

  Ejemplo TaskFlow:
  http_parser → Nest → TareasService.listar
    → JSON.stringify   ← barra ancha: payload enorme
    → em.find          ← si es ancha: consulta o N+1
*/
Protocolo de diagnóstico (orden)

1) Mira el retardo del bucle (p99). 2) Mira CPU vs I/O wait. 3) Si CPU alta → flamegraph. 4) Si CPU baja y latencia alta → base de datos, red, pool agotado, locks. 5) Si memoria crece sin límite → dos heap snapshots y comparación. No empieces por reiniciar el pod: eso borra la evidencia.

28.8 El pool de conexiones a la base de datos

Cada petición que usa MikroORM necesita una conexión (o la reutiliza del pool). Si tienes 4 réplicas de Nest con pool.max = 20 cada una, puedes estar abriendo 80 conexiones contra PostgreSQL. Si el servidor está configurado con max_connections = 100, te quedan 20 para migraciones, admin y picos: un mal cálculo aquí produce errores remaining connection slots are reserved que parecen «aleatorios».

mikro-orm.config.ts — dimensión consciente
export default defineConfig({
  // ...
  pool: {
    // Fórmula práctica:
    // max_total ≈ (max_connections_postgres * 0.7) / num_replicas
    // Deja margen para migraciones, métricas y humanos.
    min: 2,
    max: 10,
    // Cuánto esperar un hueco libre antes de fallar la petición
    acquireTimeoutMillis: 5_000,
  },
});
tareas.service.tsINCORRECTO
async detalle(id: string) {
  const t = await this.em.findOneOrFail(Tarea, id);
  // Cada acceso lazy dispara otra query (N+1)
  const autor = await t.autor.load();
  const proyecto = await t.proyecto.load();
  return { t, autor, proyecto };
}
tareas.service.tsCORRECTO
async detalle(id: string) {
  return this.em.findOneOrFail(Tarea, id, {
    populate: ['autor', 'proyecto', 'etiquetas'],
  });
}
Pool agotado ≠ «PostgreSQL lento»

Si las peticiones se quedan colgadas exactamente acquireTimeoutMillis y luego fallan, el pool está saturado: o hay demasiadas consultas largas, o hay transacciones abiertas que no hacen commit/rollback, o el max es demasiado bajo para la concurrencia. Revisa transacciones olvidadas y idle in transaction en PostgreSQL (capítulo 19) antes de subir a ciegas el max.

28.9 Configurar el proceso para producción

28.9.1 Memoria, warnings y visibilidad

Dockerfile / entrypoint
# Ajusta el montón al tamaño del contenedor (ejemplo: límite 512Mi)
# Deja ~100–150 MB para código nativo, stacks y buffers.
NODE_OPTIONS="--max-old-space-size=350 --enable-source-maps"

# En desarrollo útil; en producción suele sobrar el ruido:
# --trace-warnings

node dist/main.js

28.9.2 Apagado ordenado

Kubernetes envía SIGTERM antes de matar el contenedor. Si no cierras el servidor HTTP, el pool de BD y las colas, cortarás peticiones a mitad y dejarás transacciones abiertas.

src/main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.enableShutdownHooks(); // escucha SIGTERM/SIGINT

  // Tras SIGTERM: deja de aceptar conexiones nuevas,
  // espera a que terminen las in-flight (con tope),
  // cierra MikroORM, Redis, etc.
  const server = await app.listen(process.env.PORT ?? 3000);

  const cierre = async (senal: string) => {
    console.log(`Señal ${senal}: cerrando…`);
    // tope: si una petición no termina, igual hay que morir
    const tope = setTimeout(() => process.exit(1), 25_000);
    await app.close(); // dispara OnModuleDestroy
    clearTimeout(tope);
    process.exit(0);
  };

  process.on('SIGTERM', () => void cierre('SIGTERM'));
  process.on('SIGINT', () => void cierre('SIGINT'));
}
src/orm/orm.shutdown.ts
@Injectable()
export class OrmShutdown implements OnModuleDestroy {
  constructor(private readonly orm: MikroORM) {}

  async onModuleDestroy() {
    await this.orm.close(true); // espera a conexiones del pool
  }
}

28.9.3 Comprobaciones de salud (liveness vs readiness)

ProbePreguntaQué debe comprobarSi falla
Liveness¿El proceso está vivo?Que el bucle responde (endpoint mínimo)Reinicio del contenedor
Readiness¿Puede recibir tráfico?BD alcanzable, Redis si es crítico, no estar en drenajeSe saca del balanceador, sin matar
healthINCORRECTO
// Liveness que consulta la BD
@Get('healthz')
async live() {
  await this.em.getConnection().execute('select 1');
  return { ok: true };
}
// Si Postgres va lento, Kubernetes reinicia
// TODOS los pods → peor cascada.
healthCORRECTO
@Get('live')
live() { return { ok: true }; }

@Get('ready')
async ready() {
  await this.em.getConnection().execute('select 1');
  // opcional: retardo del bucle < umbral
  return { ok: true };
}

28.9.4 Tiempos de espera y cabeceras keep-alive

src/main.ts — timeouts del servidor HTTP
const server = app.getHttpServer();

// Tiempo máximo para recibir cabeceras (protección básica)
server.headersTimeout = 10_000;
// Tiempo máximo de inactividad en keep-alive
server.keepAliveTimeout = 5_000;
// Importante si hay balanceador: keepAliveTimeout del Node
// debe ser MAYOR que el idle timeout del LB, o el LB reutiliza
// conexiones que Node ya cerró → 502 esporádicos.
502 fantasma detrás de un balanceador

Un patrón clásico: el balanceador tiene idle timeout de 60 s y Node tiene keepAliveTimeout de 5 s. El balanceador reenvía una petición por una conexión que Node ya cortó. Sube el keepAliveTimeout de Node por encima del del balanceador (p. ej. 65 s) o alinea ambos documentadamente.

28.9.5 Procesos hijos frente a hilos

Antes de los worker threads, la forma habitual de sacar trabajo pesado del hilo principal era child_process: un proceso de sistema operativo aparte. Sigue siendo la herramienta correcta cuando necesitas ejecutar un binario nativo (conversión de imágenes, CLI de PDF, un script en otro lenguaje) o aislar un fallo que podría tumbar V8 entero. Un hilo de trabajo comparte el proceso y es más ligero para JavaScript CPU-bound; un hijo aísla mejor y habla por stdin/stdout o IPC.

Worker threadChild process
MemoriaMismo proceso; isolate V8 propioProceso OS completo; más pesado
FallosMás acoplado al padreAislamiento fuerte: el hijo muere solo
DatosClonado / transfer / shared bufferstdin/stdout, IPC, ficheros
Caso idealCálculo JS dentro de NodeBinarios externos y sandboxes
src/adjuntos/miniatura.service.ts
import { Injectable } from '@nestjs/common';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const execFileAsync = promisify(execFile);

@Injectable()
export class MiniaturaService {
  /**
   * NUNCA uses exec() con strings interpoladas: inyección de shell.
   * execFile evita el shell y pasa argumentos como array.
   */
  async generar(ruta: string, destino: string) {
    await execFileAsync('convert', [
      ruta, '-resize', '200x200>', destino,
    ], { timeout: 15_000, maxBuffer: 2 * 1024 * 1024 });
  }
}
peligroINCORRECTO
exec(`convert ${nombre} out.png`);
// nombre = "a.png; rm -rf /" → shell injection
seguroCORRECTO
execFile('convert', [nombre, 'out.png']);
// Valida además que la ruta está bajo un
// directorio permitido (path traversal).
Contexto

Node nació en 2009 para I/O concurrente. El modelo «un hilo de JS + notificaciones del SO» ganó en APIs. El precio fue el trabajo CPU-bound: durante años la respuesta era sacar un proceso o no hacerlo en Node. Los worker threads cerraron parte del hueco; no eliminan colas ni binarios externos.

28.9.6 Observabilidad mínima del runtime

Sin métricas del proceso solo ves síntomas (502, reinicios). Con unas pocas series temporales triageas la mayoría de incidentes de Node en TaskFlow: retardo del bucle p99, heap usado y RSS, conexiones activas y en espera del pool, más latencia p99 por ruta y tasa de 5xx. OpenTelemetry cubre trazas distribuidas (Angular → Nest → Postgres); el lag y el pool siguen siendo métricas locales imprescindibles.

métricas por defecto (prom-client)
import client from 'prom-client';

const register = new client.Registry();
client.collectDefaultMetrics({ register });
// Expón GET /metrics solo en red interna o con autenticación.

En incidentes, el orden de lectura recomendado es: ¿subió el lag? → perfil de CPU. ¿Lag bajo y p99 alto? → dependencia o pool. ¿Heap en rampa? → instantáneas. ¿Reinicios sin OOM? → probes mal diseñados o salida por excepción no capturada. Ese árbol evita el «reinicia y reza» que borra evidencia. Documenta umbrales en el runbook: por ejemplo lag p99 > 100 ms durante cinco minutos pagina al equipo; heapUsed por encima del 80 % del max-old-space investiga antes del OOM; waiting en el pool sostenido revisa consultas y tamaño del pool.

Runbook corto

Una alerta sin procedimiento es ruido. Cada umbral de TaskFlow debe decir: qué mirar primero, qué gráfico abrir, cuándo escalar y cuándo ampliar réplicas frente a cuándo cazar un bloqueo. El capítulo 13 profundiza en SLI/SLO; aquí basta con no volar a ciegas.

28.10 Errores no capturados y promesas rechazadas

Un throw fuera de un try/catch en el hilo principal, o una promesa rechazada sin .catch, puede tumbar el proceso o dejarlo en un estado indefinido según la versión de Node y los flags.

src/main.ts — último recurso, no sustituto de try/catch
process.on('uncaughtException', (err) => {
  console.error('uncaughtException', err);
  // Lo correcto en la mayoría de servicios: registrar, métrica, y
  // salir. Seguir vivo tras un estado desconocido es peor.
  process.exit(1);
});

process.on('unhandledRejection', (reason) => {
  console.error('unhandledRejection', reason);
  process.exit(1);
});
No «tragues» unhandledRejection y sigas como si nada

Algunos tutoriales registran el rechazo y continúan. Eso esconde bugs (una transacción a medias, un socket huérfano) y produce corrupción sutil. En TaskFlow: log estructurado + salida + que el orquestador reinicie limpio. Los errores de negocio van en filtros de Nest (capítulo 10), no aquí.

async con contexto — patrón correcto en servicios
async asignar(tareaId: string, usuarioId: string) {
  try {
    return await this.em.transactional(async (em) => {
      const tarea = await em.findOneOrFail(Tarea, tareaId);
      tarea.asignadoA = em.getReference(Usuario, usuarioId);
      await em.flush();
      return tarea;
    });
  } catch (e) {
    this.log.error(`asignar falló`, { tareaId, usuarioId, e });
    throw e; // que el filtro global traduzca a HTTP
  }
}

28.11 Pruebas de carga: qué mirar

Una prueba de carga no sirve para presumir de «X peticiones por segundo». Sirve para encontrar el punto en el que el p99 se dispara, el pool se agota o el bucle se retrasa. Enlaza con el capítulo 13 (k6) y con la descarga de carga de la sección 28.3.

carga/listar-tareas.js — k6 (esqueleto)
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '1m', target: 20 },
    { duration: '3m', target: 80 },
    { duration: '1m', target: 0 },
  ],
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(99)<500'],
  },
};

export default function () {
  const res = http.get('http://localhost:3000/api/tareas?limit=50', {
    headers: { Authorization: `Bearer ${__ENV.TOKEN}` },
  });
  check(res, { '200': (r) => r.status === 200 });
  sleep(0.3);
}
Síntoma bajo cargaCausa probableDónde mirar
p99 sube, CPU bajaEspera en BD / red / poolConsultas lentas, locks, acquireTimeout
p99 sube, CPU al 100 %Bloqueo del bucleFlamegraph, event loop delay
Errores de conexión a PostgresPool × réplicas > max_connectionsConfig pool + Postgres
502 del ingressTimeouts keep-alive / pod caídoSección 28.9.4, probes
Memoria en sierra hasta OOMFuga o buffers sin contrapresiónHeap snapshots, streams

28.12 Checklist de despliegue Node/Nest

  • NODE_ENV=production y secretos fuera del código (capítulo 21).
  • --max-old-space-size alineado con el límite del contenedor.
  • Apagado ordenado con enableShutdownHooks y cierre del ORM.
  • Probes: live barato, ready con dependencias.
  • Métrica de retardo del bucle (p99) en el panel y con alerta.
  • Pool de BD dimensionado por réplica; N+1 eliminados en rutas calientes.
  • Sin estado crítico solo en memoria si hay más de una réplica.
  • Logs estructurados JSON; sin cuerpos completos ni tokens.
  • Límite de tamaño de body y de subidas (DoS trivial).
  • Prueba de carga en staging con el mismo tamaño de máquina que producción.
  • Binarios externos solo vía execFile con timeout, nunca exec con shell.
  • Métricas de lag, heap y pool exportadas; runbook con umbrales.
  • uncaughtException / unhandledRejection: log y salida, no tragar el error.

Un detalle que suele olvidarse en checklists es la paridad de entorno: si producción tiene 2 CPU y 512 MB y staging tiene 8 CPU y 4 GB, tus pruebas de carga mentirán. Reproduce límites de CPU y memoria del contenedor también en staging. Otro detalle: el tamaño del bundle y el coste de arranque frío. Un proceso Nest que tarda 40 s en importar módulos hará fallar el startupProbe aunque el código sea correcto; mide time until listen y recorta imports pesados al arranque (carga diferida de rutas admin, por ejemplo).

Respecto a la seguridad del runtime, además del capítulo 12: desactiva stack traces detallados hacia el cliente en producción, limita la superficie de /metrics y de cualquier endpoint de heap snapshot, y no ejecutes el contenedor como root. Node no te obliga a nada de eso; el orquestador y la imagen sí.

Señales de un servicio Node «maduro»

Lag p99 estable en verde; reinicios solo en despliegues; pool sin cola de espera crónica; memoria en sierra por GC, no en rampa; 503 ocasional bajo pico extremo en lugar de timeout masivo; logs con los que se puede seguir una petición de punta a punta. Si solo tienes «el pod está Running», no tienes producción: tienes esperanza.

28.13 Errores comunes y cómo solucionarlos

SíntomaCausaSolución
Latencia alta con CPU al 30 %Esperas I/O o pool, no falta de CPUNo añadas réplicas a ciegas; mira BD y pool
Proceso «congelado» sin crashBucle bloqueado o inanición por nextTickEvent loop delay + perfil; evita sync APIs
Login lento bajo picoThread pool de 4 con bcrypt/pbkdf2Async hash, rate limit, cola o más pool con criterio
UV_THREADPOOL_SIZE «no hace nada»Se setea después de cargar módulosVariable de entorno al arrancar el proceso
OOM al exportar CSVfind() + string giganteStream + cursor + contrapresión
Memoria crece cada díaCaché Map sin TTLLRU/Redis; heap snapshots
Cron se ejecuta N vecesN réplicas sin lockLock Redis/BD o un solo worker
502 esporádicos tras LBkeepAlive desalineadoAjustar timeouts (28.9.4)
Pods en crash loopLiveness apunta a BD caídaSeparar live/ready
Peticiones cuelgan 5 s y fallanPool agotadoTransacciones largas, subir max con cuenta
Worker thread no aceleraCoste de clonar datos > cálculoMedir; transferir buffers; o cola
Fuente maps no ayudanSin --enable-source-mapsActivarlos en producción con cuidado de tamaño
Health OK pero usuarios caídosHealth no refleja bucle saturadoIncluir umbral de lag en ready o load-shed
JSON enorme bloqueaJSON.stringify síncronoPaginar; streaming; worker
Regex «hang»Backtracking catastróficoValidar longitud; patrones lineales

28.14 Buenas y malas prácticas

Buenas prácticas

  • Medir el retardo del bucle en producción.
  • Preferir I/O async y APIs con promesas.
  • Streams con pipeline y contrapresión.
  • Dimensionar el pool de BD por réplica.
  • Populate explícito; cero N+1 en rutas calientes.
  • Apagado ordenado y probes separados.
  • Estado compartido en Redis/BD, no en memoria.
  • Perfilar antes de «optimizar».
  • Límites de body, upload y tiempo.
  • Rechazar carga (503) antes que colapsar.
  • Logs con correlación; sin secretos.
  • Pruebas de carga con umbrales de p99.

Malas prácticas

  • *Sync en el camino de una petición HTTP.
  • Cachés Map infinitos.
  • Un worker thread por petición.
  • Cluster + estado en memoria sin redesign.
  • Liveness acoplado a Postgres.
  • Ignorar unhandledRejection.
  • Subir max_connections sin entender el pool.
  • Acumular ficheros en memoria «porque Multer».
  • Reiniciar pods como «arreglo» de fugas.
  • Optimizar sin flamegraph ni métricas.
  • Recursión con process.nextTick.
  • Prometer «Node escala solo» sin medir.

28.15 Preguntas frecuentes

¿Node.js es realmente de un solo hilo?
Tu JavaScript corre en un solo hilo. El proceso tiene más hilos: el pool de libuv (fs, crypto, dns.lookup, zlib) y los worker threads que tú crees. La red usa notificaciones del SO, no un hilo por conexión. Por eso «un solo hilo» es una media verdad: un solo hilo de JS, varios hilos de soporte.
¿Cuándo uso worker threads frente a BullMQ?
Worker threads: cálculo corto/medio donde la latencia de la respuesta HTTP importa y los datos caben en un mensaje. BullMQ (u otra cola): trabajos de segundos/minutos, reintentos, supervivencia a reinicios, fan-out a varios consumidores. Si el trabajo debe vivir aunque caiga el pod, cola externa.
¿Qué es el event loop lag y por qué importa más que la CPU?
Es cuánto se retrasan los timers respecto al reloj. CPU al 40 % con lag de 300 ms significa que el hilo de JS está ocupado a rachas (o esperando mal) y tus p99 ya son malos. CPU al 90 % con lag bajo puede ser trabajo útil bien repartido. Alerta sobre p99 de lag.
¿Por qué setTimeout(fn, 0) no corre «en el siguiente tick»?
Entra en la fase timers de la siguiente iteración del bucle (con resolución mínima del sistema). Las microtareas de promesas y process.nextTick corren antes, entre fases. «Siguiente tick» es lenguaje vago: di la fase concreta.
¿Debo usar el módulo cluster en Kubernetes?
Casi nunca. Escala con réplicas del Deployment. Cluster dentro del pod complica señales, logs y memoria, y no reparte entre nodos. Úsalo en una VM única sin orquestador, si acaso.
¿Cómo dimensiono --max-old-space-size?
Mira el límite de memoria del contenedor y deja margen (cientos de MB) para stacks, código nativo, Buffers y fragmentación. Si el límite es 512 Mi, algo en torno a 300–350 suele ser punto de partida; valida con carga. Si pones el heap igual al límite del cgroup, el OOM killer del SO llega sin que V8 pueda GC con holgura.
¿Los Buffers cuentan para el heap de V8?
Parte de su memoria aparece en external / arrayBuffers de memoryUsage(), no solo en heapUsed. Puedes tener heap «bajo» y RSS alto por Buffers. Por eso los streams y los límites de upload importan.
¿Qué hago con una fuga que solo aparece tras días?
Métricas de heapUsed y RSS en el tiempo; en una réplica canario, dos heap snapshots separados por horas de tráfico sintético; busca retenedores que crecen (caches, listeners). No basta con reiniciar cada noche: eso es un vendaje.
¿Nest con Fastify es «más rápido» y resuelve mis problemas?
Fastify suele mejorar throughput de serialización y overhead HTTP, pero no arregla N+1, pools mal dimensionados ni JSON de 20 MB. Mide tu cuello de botella; cambiar de adapter sin perfilar es teatro de rendimiento.
¿Por qué mi probe de readiness tumba el despliegue?
Si durante el arranque la BD aún no está lista y el ready falla, el pod no recibe tráfico (correcto). Si el liveness falla por lo mismo, Kubernetes reinicia sin parar. Separa live/ready y da initialDelaySeconds / startupProbe adecuados.
¿Puedo usar fs.readFileSync al arrancar?
Al bootstrap, antes de aceptar tráfico, un sync corto (leer un secreto montado, un cert) es aceptable. En el camino de cada request, no. Si el arranque lee cientos de MB sync, retrasas readiness: mejor async o construir la imagen con los assets ya listos.
¿Qué relación hay entre este capítulo y el 21 (Docker/CI)?
El 21 despliega el contenedor; este enseña qué debe hacer el proceso dentro para no morir bajo carga: memoria, señales, probes, métricas. Un Dockerfile perfecto con un Node que bloquea el event loop sigue siendo un mal servicio.
¿Cómo evito el thundering herd al recuperar tras un corte?
Backoff con jitter en clientes, límites de concurrencia, load shedding (503 + Retry-After), circuit breakers hacia dependencias, y colas para trabajo diferible. Si todos los clientes reintentan a la vez, recreas el corte.
¿MikroORM en worker threads?
Cada thread es un isolate distinto: no compartas EntityManager ni conexiones. Si el worker necesita BD, abre su propio contexto o, mejor, pasa datos ya leídos desde el hilo principal y deja al worker solo el cálculo. Mezclar ORM + workers sin diseño claro es fuente de fugas y deadlocks.

28.16 Ejercicios

Nivel 1 · básico
  1. Reproduce el orden de salida del ejemplo de timers/microtareas y explica cada línea.
  2. Mide process.memoryUsage() cada 2 s en un endpoint que mete objetos en un Map global; observa el crecimiento.
  3. Sustituye ese Map por un LRU con TTL y vuelve a medir.
  4. Configura probes live/ready distintos en un manifiesto de Kubernetes de ejemplo y justifica cada uno.
  5. Escribe un export CSV con find() y otro con stream; compáralos con un dataset grande en local.
Nivel 2 · intermedio
  1. Instrumenta monitorEventLoopDelay en Nest y genera un bloqueo artificial (Atomics.wait o bucle) para ver el p99.
  2. Implementa un middleware de load shedding cuando el p99 del bucle > 200 ms.
  3. Dimensiona el pool de MikroORM para 3 réplicas y un Postgres con max_connections=100; documenta la cuenta.
  4. Monta un grupo de worker threads para un informe agregado y mide el coste de clonar 100k filas.
  5. Perfila con --cpu-prof un endpoint lento y entrega el flamegraph interpretado (qué función y por qué).
Nivel 3 · avanzado
  1. Simula un 502 por keepAlive desalineado (proxy + Node) y corrígelo.
  2. Introduce una fuga por listener, demuéstrala con dos heap snapshots y arréglala.
  3. Prueba de carga k6 sobre listado de tareas con y sin populate incorrecto; compara p99 y CPU.
  4. Implementa apagado ordenado que espere peticiones in-flight con tope de 25 s y verifica con SIGTERM.
  5. Diseña la estrategia completa de paralelismo para «generar PDF de proyecto» en TaskFlow (HTTP 202 + cola + worker).
Solución comentada: load shedding por event loop delay
src/comun/toobusy.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { monitorEventLoopDelay } from 'node:perf_hooks';

const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();

@Injectable()
export class TooBusyMiddleware implements NestMiddleware {
  use(_req: Request, res: Response, next: NextFunction) {
    const p99ms = h.percentile(99) / 1e6;
    h.reset();
    if (p99ms > 200) {
      res.setHeader('Retry-After', '2');
      res.status(503).json({
        title: 'Servidor saturado',
        detail: 'Reintenta en unos segundos',
        status: 503,
      });
      return;
    }
    next();
  }
}

La idea no es castigar al usuario: es preservar capacidad para las peticiones que ya puedes atender bien. Combínalo con alertas; si el 503 es frecuente, tienes un problema de capacidad o de bloqueo, no un middleware «malo».

Solución comentada: cuenta del pool de conexiones

Supón Postgres max_connections = 100. Reserva ~20 para admin, migraciones y herramientas → 80 útiles. Con 4 réplicas de API: 80 / 4 = 20 como techo teórico por proceso. Empieza más bajo (max: 10) y sube solo si ves espera en acquire sin consultas lentas. Si pones 20 en cada una y además un worker de Bull con su propio pool de 10, te pasas: cuenta todos los procesos que abren conexiones.

Solución comentada: CSV con contrapresión
exportar con drain
import { once } from 'node:events';

async function escribirLinea(res: NodeJS.WritableStream, linea: string) {
  if (!res.write(linea)) {
    await once(res, 'drain');
  }
}

Sin drain, un cliente lento convierte tu «stream» en un buffer gigante en RAM. Con pipeline hacia la respuesta HTTP (donde el framework lo permita) reduces código y errores.

28.17 Resumen del capítulo

  • Un solo hilo ejecuta tu JS; libuv y el SO hacen el resto. Bloquear ese hilo paraliza todo el proceso.
  • Conoce las fases del bucle y el peligro de nextTick recursivo.
  • El retardo del bucle (p99) es la métrica de salud del proceso.
  • Elige paralelismo con disciplina: nada → trocear → BD → workers → cola.
  • Streams + contrapresión evitan OOM; pipeline supera a pipe.
  • Las fugas son referencias vivas; se cazan con snapshots, no con fe.
  • Perfila CPU con flamegraphs antes de optimizar.
  • El pool de BD se multiplica por réplicas: haz la cuenta.
  • Producción: límites de heap, apagado ordenado, live≠ready, timeouts alineados con el LB.
  • Estado en memoria no sobrevive al segundo proceso ni a la segunda réplica.

28.18 Recursos adicionales

Enlace con el resto del libro

Este capítulo aterriza el runtime bajo Nest (9–13), las colas (11), SQL y locks (19), Docker/K8s (21) y las pruebas de carga (13). El 29 sigue con otro clásico de producción: fechas, zonas horarias e i18n.

Cierre didáctico: el camarero otra vez

Si el comedor (tu hilo de JS) está pelando patatas (JSON enorme, bcrypt sync, regex diabólica), da igual cuántas mesas (conexiones) tengas: nadie come. Si la cocina (Postgres) solo tiene cuatro fogones (pool) y abres cuatro restaurantes (réplicas) con veinte camareros pidiendo platos a la vez, la cocina explota. Si el almacén del restaurante (montón) no se vacía nunca porque alguien guarda cada ticket en una caja sin tapa (Map infinito), un día no cabe nadie. Producción en Node es, sobre todo, no confundir «aceptar muchas conexiones» con «hacer trabajo síncrono barato» ni «reiniciar el pod» con «haber entendido el fallo».

Antes de dar por cerrado un incidente de rendimiento en TaskFlow, deja por escrito tres frases: qué métrica se movió, qué evidencia (flamegraph, snapshot, EXPLAIN, log de pool) lo confirma, y qué cambio concreto lo mitiga. Sin esas tres frases, el siguiente turno volverá a empezar de cero. Esa disciplina, más que cualquier flag de V8, es lo que separa un equipo que opera Node en serio de uno que solo lo despliega. Guarda esas notas junto a la alerta: el conocimiento operativo también es código. Y revisa el postmortem a la semana: si la misma clase de fallo se repite, el arreglo fue incompleto y hay que seguir investigando con datos nuevos.