En las tres lecciones anteriores hemos dado muchos números: 641 peticiones por segundo, luego 3 105, luego 11 840; percentiles 99 de 204, 54 y 11 milisegundos; un retraso del bucle de eventos que pasó de 4 176 ms a 11 ms. Esos números no salieron de la intuición: salieron de medir antes, cambiar una cosa y medir después. Esa práctica, que hemos aplicado sin nombrarla, es el contenido de esta lección.

Aquí no vas a encontrar una lista de trucos, sino un método, un catálogo de instrumentos (pruebas de carga, perfiles de CPU, flamegraphs, instantáneas del montículo) y un catálogo de cuellos de botella típicos de Node con su arreglo, todos anclados a lo que ya sabes del curso.

Contenido

  1. El método: medir, localizar, arreglar, volver a medir
  2. Qué se mide: latencia, rendimiento, errores, saturación
  3. Pruebas de carga con autocannon
  4. Perfilado de CPU y flamegraphs
  5. Perfilado de memoria y fugas
  6. El retraso del bucle de eventos como métrica de salud
  7. Catálogo de cuellos de botella típicos en Node
  8. Optimizaciones de la capa HTTP
  9. La base de datos: el pool de conexiones
  10. V8 sin caer en el micro-optimizado inútil
  11. El orden correcto de las palancas

  1. El método: medir, localizar, arreglar, volver a medir

El ciclo tiene cuatro pasos y ninguno es opcional: medir el sistema completo bajo una carga realista y anotar la línea base; localizar el cuello de botella con un perfil, no con una corazonada; arreglar solo eso, un cambio cada vez; y volver a medir con la misma prueba, comparando con la línea base.

Un cambio por iteración es innegociable. Si tocas tres cosas y el sistema mejora un 30 %, no sabes cuál de las tres funcionó, ni si alguna empeoró las cosas y las otras dos lo compensaron.

La cita de Donald Knuth se repite mucho y casi siempre mutilada. La frase completa es: "deberíamos olvidarnos de las pequeñas eficiencias, digamos el 97 % del tiempo: la optimización prematura es la raíz de todos los males. Sin embargo, no deberíamos dejar pasar nuestras oportunidades en ese 3 % crítico." Los dos extremos son errores: reescribir un bucle para ahorrar microsegundos en código que se ejecuta una vez al día, y también ignorar que tu consulta principal hace un escaneo secuencial sobre una tabla de un millón de filas. La diferencia entre ambos no es filosófica: es que uno lo has medido y el otro no. Corolario práctico: optimizar sin medir es adivinar, y la intuición del programador sobre dónde está el tiempo es notoriamente mala. En Escena Viva cualquiera habría apostado a que el cuello estaba en generar el PDF; el perfil demostró que en el catálogo el 62 % del tiempo se iba en serializar JSON y en una consulta sin índice.

  1. Qué se mide: latencia, rendimiento, errores, saturación

Cuatro señales, conocidas como las golden signals:

Señal Qué es Cómo se mide en Escena Viva
Latencia Tiempo de respuesta Percentiles por ruta, separando 2xx de 5xx
Rendimiento (throughput) Peticiones atendidas por segundo autocannon, y contadores en producción
Tasa de errores Proporción de 5xx y de tiempos agotados Registro estructurado (M6) por codigo de error
Saturación Cuán lleno está el recurso limitante CPU, memoria, conexiones del pool, retraso del bucle

Sobre la latencia hay que insistir en algo, y es que la media miente:

Estadístico Valor en el catálogo Qué significa
Media / p50 78 ms / 72 ms Ningún usuario concreto experimenta la media
p95 161 ms 1 de cada 20 peticiones va peor
p99 204 ms 1 de cada 100. Con 12 peticiones por página, 1 de cada 8 visitas ve esto
p99.9 / máximo 890 ms La cola larga: lo que provoca las quejas

Si diez peticiones tardan 10 ms y una tarda 5 000 ms, la media es 463 ms: un número que no describe ni a las rápidas ni a la lenta. Y la aritmética del p99 asusta cuando se traduce a experiencia: una página que hace 12 llamadas a la API tiene una probabilidad de 1 − 0,99¹² ≈ 11 % de encontrarse al menos una petición del percentil 99. Optimizar la cola, no la media. Estos son los objetivos de servicio de Escena Viva, acordados con el negocio:

Ruta Objetivo p99 Disponibilidad Justificación
GET /api/eventos < 150 ms 99,9 % Es la primera pantalla; si va lenta, no se vende
GET /api/eventos/:id/sesiones < 200 ms 99,9 % Paso previo a la compra
POST /api/pedidos < 800 ms 99,95 % Transaccional; se tolera más latencia, no fallos
POST /api/autenticacion/login < 400 ms 99,9 % bcrypt con coste 12 tiene un suelo propio

Ese último caso demuestra que no todo lo lento es un problema: bcrypt con coste 12 tarda ~250 ms a propósito, porque esa lentitud es la defensa contra la fuerza bruta (M8), y optimizarla sería una regresión de seguridad.

  1. Pruebas de carga con autocannon

Una prueba de carga honesta cumple cuatro condiciones. Calentamiento: los primeros segundos no cuentan, porque V8 necesita ejecutar el código varias veces antes de que el compilador optimizador (TurboFan) haga su trabajo y las cachés están frías. Duración suficiente: 30-60 segundos mínimo, ya que diez segundos pueden esconder una fuga o el efecto de una recolección de basura mayor. Concurrencia realista: no 5 000 conexiones si tu pico real son 200. Y escenario realista: no martillear una sola ruta trivial, sino reproducir el recorrido del usuario.

# Calentamiento (10 s que se descartan) y medicion (60 s, 100 conexiones).
npx autocannon -c 50 -d 10 -w 4 http://localhost:3000/api/eventos > /dev/null
npx autocannon -c 100 -d 60 -w 4 --latency http://localhost:3000/api/eventos

Un escenario de compra realista se define por programa:

'use strict';

const autocannon = require('autocannon');

// Escenario del estreno del Festival de Jazz: mirar catalogo, abrir la
// ficha del evento, consultar sesiones y, en una fraccion, comprar.
const instancia = autocannon({
  url: 'http://localhost:3000',
  connections: 100,
  duration: 60,
  warmup: { connections: 10, duration: 10 },
  requests: [
    { method: 'GET', path: '/api/eventos' },
    { method: 'GET', path: '/api/eventos/evt-003' },
    { method: 'GET', path: '/api/eventos/evt-003/sesiones' },
    { method: 'POST', path: '/api/pedidos',
      headers: { 'content-type': 'application/json', authorization: 'Bearer <token>' },
      body: JSON.stringify({ sesionId: 'ses-003-1', cantidad: 2 }) },
  ],
});

autocannon.track(instancia, { renderProgressBar: true });
instancia.on('done', (r) => console.log(`p99: ${r.latency.p99} ms, no-2xx: ${r.non2xx}`));

Y así se lee la salida:

Campo Qué mirar
Latency p99 El número que gobierna tus objetivos de servicio
Req/Sec Rendimiento; compáralo siempre con la línea base
non2xx Si no es 0, la prueba no vale: estás midiendo la velocidad de fallar
errors / timeouts Conexiones rechazadas o agotadas: has superado la capacidad
Bytes/Sec Si te acercas al ancho de banda, el cuello es la red, no tu código

k6 es la alternativa cuando necesitas escenarios con estado (login, cookies, sesiones encadenadas), umbrales que hagan fallar la prueba en CI y carga distribuida: autocannon es perfecto para el ciclo rápido de "mide, cambia, mide", y k6 para la prueba de regresión de rendimiento en integración continua (M11).

Advertencia importante y no negociable. Una prueba de carga es, técnicamente, un ataque de denegación de servicio. Lánzala solo contra sistemas que sean tuyos y en entornos de prueba: nunca contra producción sin coordinación previa (y ahí, mejor tráfico espejo o carga sintética limitada), y nunca contra una API de terceros, ni la de divisas del M8, ni la pasarela de pago, ni un servicio público. Es ilegal en muchas jurisdicciones y siempre es una falta de respeto profesional.

  1. Perfilado de CPU y flamegraphs

La prueba de carga te dice que algo es lento; el perfil te dice dónde.

Opción 1: el perfilador integrado de V8. node --prof src/servidor.js genera un isolate-*.log con muestras del contador de programa; se lanza la carga, se para el servidor y node --prof-process isolate-*.log > perfil.txt lo convierte en un informe legible que empieza con el resumen que más importa:

 [Summary]:  ticks  total  nonlib   name
              4821  61.9%   72.4%  JavaScript
              1901  24.4%          GC
               502   6.4%          Shared libraries
 [Bottom up (heavy) profile]:
  1204   15.5%  JSON.stringify
   890   11.4%    LazyCompile: *serializarCatalogo src/repositorios/eventos.js:88
   731    9.4%  LazyCompile: *calcularAforoDisponible src/dominio/sesion.js:41

Dos lecturas inmediatas: JSON.stringify se lleva el 15,5 % y la recolección de basura el 24,4 %, lo que sugiere que estamos creando demasiados objetos temporales. El * delante del nombre significa que V8 optimizó esa función; un ~ significaría que la ejecutó sin optimizar, y eso a veces es la pista de un problema de formas ocultas.

Opción 2: el inspector de Chrome DevTools. Con node --inspect src/servidor.js y chrome://inspect tienes perfil de CPU con gráfico de llamas, instantáneas del montículo y comparación entre ellas en una sola interfaz. En un servidor remoto se usa --inspect=0.0.0.0:9229 con un túnel SSH, nunca expuesto a internet: el inspector permite ejecutar código arbitrario en tu proceso.

Opción 3: 0x y los flamegraphs.

npx 0x --output-dir perfiles -- node src/servidor.js
# Se lanza la carga, se para con Ctrl+C y se abre el HTML generado.

Cómo se lee un flamegraph, que es donde casi todo el mundo se equivoca:

  • El eje horizontal NO es el tiempo, sino la proporción de muestras; las funciones se ordenan alfabéticamente, no cronológicamente.
  • La anchura de una caja es el tiempo total en esa función, incluyendo lo que llamó. Es lo único que importa buscar: cajas anchas.
  • La altura es la profundidad de la pila. Una torre muy alta y estrecha es solo código con muchas capas: no es un problema.
  • Una caja ancha con una meseta plana encima significa que el tiempo se consume en ella misma, no en sus llamadas: ahí está tu función cara.
  • El color no significa nada por defecto (salvo en 0x, que resalta las funciones no optimizadas).

En el flamegraph de Escena Viva antes de la caché, una meseta ancha sobre obtenerCatalogo → mapearEventos → JSON.stringify ocupaba casi dos tercios del ancho; tras la lección 10-03 esa meseta desaparece y el ancho lo ocupa el bucle ocioso de libuv, que es exactamente lo que quieres ver.

  1. Perfilado de memoria y fugas

Una fuga de memoria en Node no se manifiesta como un error, sino como una degradación: el proceso crece, la recolección de basura trabaja cada vez más (y sus pausas son tiempo robado a tu bucle de eventos), y finalmente el proceso muere con JavaScript heap out of memory. Método de diagnóstico con instantáneas del montículo: arranca el proceso y déjalo estabilizarse; toma la instantánea A (DevTools → Memory → Heap snapshot, o v8.writeHeapSnapshot()); aplica carga sostenida durante varios minutos; fuerza una recolección y toma la instantánea B; y en DevTools elige la vista Comparison entre B y A.

La columna decisiva es Delta: cuántos objetos de cada tipo hay de más. Si Array o un constructor tuyo crece de forma monótona instantánea tras instantánea, ahí está la fuga; después, Retainers te dice quién impide que el recolector se los lleve.

'use strict';

// v8.writeHeapSnapshot(ruta) toma la instantanea bajo demanda, tambien en
// produccion (con cuidado: pausa el proceso y genera cientos de MB).
// Y esto es la vigilancia barata y continua del uso de memoria.
function registrarUsoDeMemoria(registrador) {
  const uso = process.memoryUsage();
  const mb = (bytes) => Math.round(bytes / 1048576);
  registrador.info({ rssMb: mb(uso.rss), monticuloUsadoMb: mb(uso.heapUsed),
    monticuloTotalMb: mb(uso.heapTotal), externaMb: mb(uso.external) });
}

module.exports = { registrarUsoDeMemoria };

Las dos fugas que verás en la vida real, y las dos aparecieron en Escena Viva. La primera, caché sin límite: antes de la lección 10-03, la caché de divisas era un Map en memoria que con pares fijos no crecía, pero que si la clave hubiera incluido el importe (EUR:USD:4500) habría crecido sin fin. Toda caché en memoria necesita un techo con expulsión LRU (lru-cache) o irse a Redis, que caduca sola. La segunda, oyentes no desregistrados: nuestro GestorDeVentas es un EventEmitter (M2), y si cada petición registra un oyente que nadie quita, el emisor acumula referencias a los cierres y con ellos a todo lo que capturan.

'use strict';

// MAL: el oyente sobrevive a la peticion y retiene 'respuesta' para
// siempre, y con ella el socket, las cabeceras y el cuerpo.
const malControlador = (gestor) => (peticion, respuesta) => {
  gestor.on('venta-registrada', (venta) => respuesta.write(JSON.stringify(venta)));
};

// BIEN: se registra, y se retira cuando la peticion termina. El evento
// 'close' cubre el final normal y la desconexion del cliente.
const buenControlador = (gestor) => (peticion, respuesta) => {
  const alVender = (venta) => respuesta.write(JSON.stringify(venta));
  gestor.on('venta-registrada', alVender);
  respuesta.on('close', () => gestor.off('venta-registrada', alVender));
};

module.exports = { buenControlador };

El aviso MaxListenersExceededWarning: Possible EventEmitter memory leak detected es Node diciéndote literalmente que tienes esta fuga. Nunca lo silencies subiendo setMaxListeners: arregla el desregistro.

  1. El retraso del bucle de eventos como métrica de salud

Si tuvieras que elegir una sola métrica para saber si un proceso Node está sano, sería esta: la CPU al 90 % puede ser normal, pero un retraso del bucle de 800 ms nunca lo es.

'use strict';

const { monitorEventLoopDelay } = require('node:perf_hooks');

// resolution: cada cuantos ms se toma una muestra. 10 ms es buen punto.
function crearVigilanteDelBucle({ registrador, intervaloMs = 30_000, umbralP99Ms = 100 }) {
  const histograma = monitorEventLoopDelay({ resolution: 10 });
  histograma.enable();
  const ms = (valor) => Number((valor / 1e6).toFixed(2));

  const temporizador = setInterval(() => {
    const p99 = ms(histograma.percentile(99));
    registrador[p99 > umbralP99Ms ? 'warn' : 'info']({
      metrica: 'retraso-bucle-eventos', p99Ms: p99,
      p50Ms: ms(histograma.percentile(50)), maximoMs: ms(histograma.max),
    });
    histograma.reset(); // ventana deslizante, no acumulativa
  }, intervaloMs);

  temporizador.unref();
  return { detener: () => { clearInterval(temporizador); histograma.disable(); } };
}

module.exports = { crearVigilanteDelBucle };

Cómo interpretar los valores:

Retraso p99 Diagnóstico
< 10 ms Sano
10-50 ms Carga alta pero manejable
50-200 ms Hay trabajo síncrono: investiga con un perfil de CPU
> 200 ms Algo bloquea el bucle. Es el síntoma de la lección 10-02
> 1 000 ms El proceso está efectivamente caído aunque responda al ping

Es también el mejor valor para una comprobación de salud: un proceso con el bucle atascado responde 200 OK a un /salud trivial mientras deja tirados a todos los usuarios, y solo un /salud que mire el retraso del bucle detecta ese estado.

  1. Catálogo de cuellos de botella típicos en Node

Cuello de botella Síntoma Arreglo Referencia
E/S síncrona en caliente (readFileSync, existsSync por petición) Retraso del bucle alto, perfil con fs en la pila Versión asíncrona, o leer una vez al arrancar M3
JSON gigante serializado en cada respuesta JSON.stringify ancho en el flamegraph, GC alta Paginar, seleccionar campos, cachear el JSON ya serializado 10-03, 10-05
Consulta N+1 Muchas consultas cortas idénticas por petición include/JOIN, o DataLoader (10-06) M7
Falta de índices EXPLAIN ANALYZE muestra Seq Scan Crear el índice y verificar que se usa M7
await secuencial evitable Latencia igual a la suma de las esperas Promise.all en operaciones independientes M2
Logging síncrono El retraso del bucle sube con el volumen de registro Registrador asíncrono con buffer (pino), sin console.log en caliente M6, M11
ReDoS (retroceso catastrófico) Una petición concreta congela el proceso Regex sin ambigüedad anidada, o validación por longitud/zod previa M8
Datos cacheables regenerados CPU y base de datos altas para devolver lo mismo Cache-aside con TTL 10-03
Trabajo de CPU en el hilo principal Retraso del bucle en segundos Hilos o cola 10-02

Dos merecen desarrollo. El primero, await secuencial donde cabía Promise.all, es el error de rendimiento más común y más barato de arreglar:

'use strict';

// MAL: tres 'await' en serie sobre los tres repositorios.
// Latencia = 40 + 35 + 30 = 105 ms.
//
// BIEN: las tres son independientes. Latencia = max(40, 35, 30) = 40 ms.
async function cargarPantallaDelEvento(eventoId, repos) {
  const [evento, sesiones, sala] = await Promise.all([
    repos.eventos.obtenerEventoPorId(eventoId),
    repos.sesiones.listarPorEvento(eventoId),
    repos.salas.obtenerPorEvento(eventoId),
  ]);
  return { evento, sesiones, sala };
}

La condición es la independencia: si la segunda consulta necesita el resultado de la primera, el await secuencial es obligatorio. Y para operaciones donde el fallo de una no debe tumbar el resto, usa Promise.allSettled. El segundo es el ReDoS: expresiones regulares con retroceso catastrófico, el único cuello de botella de esta lista que además es una vulnerabilidad de seguridad (OWASP, M8), porque una sola petición de 40 caracteres puede congelar el proceso durante minutos.

'use strict';

// PELIGROSA: (\s*\w+)+ tiene ambiguedad anidada. Con una entrada como
// 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!' el motor prueba un numero exponencial
// de combinaciones antes de rendirse, y el proceso entero se congela.
const REGEX_PELIGROSA = /^(\s*\w+)+$/;
// SEGURA: sin ambiguedad, un solo cuantificador sobre clases disjuntas.
const REGEX_SEGURA = /^[\w\s]+$/;

// Y ademas: limitar la longitud ANTES de aplicar la regex.
const validarTituloDeEvento = (titulo) =>
  typeof titulo === 'string' && titulo.length <= 120 && REGEX_SEGURA.test(titulo);

module.exports = { validarTituloDeEvento };

La defensa en capas: validar longitud y forma con zod (M6) antes de cualquier regex, evitar cuantificadores anidados y desconfiar de las expresiones copiadas de internet para validar correos o URLs.

  1. Optimizaciones de la capa HTTP

Antes de tocar tu código, comprueba que la capa de transporte no regala trabajo:

Palanca Cómo Ganancia típica
Compresión compression (ya instalado), umbral de 1 KB 60-80 % menos bytes en JSON
ETag + 304 Ya lo hicimos a mano en el M4; Express lo trae Respuesta vacía en lecturas repetidas
Cache-Control public, max-age=60 en el catálogo Peticiones que no llegan a tu servidor
keep-alive saliente Agente con keepAlive: true en fetch/HTTP Ahorra el saludo TCP+TLS en cada llamada
Paginación obligatoria Límite máximo en el servidor Evita respuestas de megabytes

La compresión no es gratis: consume CPU. Con respuestas pequeñas (< 1 KB) cuesta más de lo que ahorra, y en un servidor con la CPU saturada puede ser contraproducente; en producción lo habitual es delegarla al proxy inverso (M11). Sobre las conexiones salientes, cada llamada al servicio de divisas del M8 sin keep-alive paga saludo TCP y negociación TLS: unos 40-80 ms de puro protocolo.

'use strict';

const { Agent, setGlobalDispatcher } = require('undici');

// Reutiliza conexiones para todas las llamadas salientes con fetch.
setGlobalDispatcher(new Agent({
  connections: 32,        // conexiones maximas por origen
  keepAliveTimeout: 30_000,
  keepAliveMaxTimeout: 120_000,
}));

  1. La base de datos: el pool de conexiones

Abrir una conexión a PostgreSQL cuesta entre 5 y 50 ms (autenticación, TLS, proceso en el servidor). Por eso Sequelize mantiene un pool: un conjunto de conexiones abiertas que se prestan y devuelven.

'use strict';

const os = require('node:os');
const { Sequelize } = require('sequelize');
const { configuracion } = require('../config/index.js');

// Presupuesto global de PostgreSQL: max_connections (100). Reparto:
// trabajadores de cluster x tamano del pool, mas margen para consumidores
// de cola, migraciones y administracion.
const TRABAJADORES = Math.max(1, os.availableParallelism() - 1); // 7
const MAXIMO_POR_PROCESO = Math.max(2, Math.floor(80 / (TRABAJADORES + 2)));

const sequelize = new Sequelize(configuracion.baseDeDatos.url, {
  logging: false,
  pool: {
    max: MAXIMO_POR_PROCESO, // 8 con 7 trabajadores
    min: 2,                  // conexiones calientes siempre listas
    acquire: 10_000,         // espera maxima por una conexion libre
    idle: 10_000,            // cierra las ociosas pasado este tiempo
  },
});

module.exports = { sequelize };

Cómo se dimensiona, en orden: averigua max_connections del servidor (100 por defecto en PostgreSQL); reserva margen para migraciones, consumidores de cola y administración; divide el resto entre el número de procesos que conectan; y recuerda que más grande no es mejor, porque un pool de 100 contra 4 núcleos de base de datos solo añade contención (hay una fórmula clásica que sugiere núcleos × 2 + husos de disco como punto de partida).

Qué pasa cuando el pool se agota: las peticiones esperan hasta acquire y luego fallan con SequelizeConnectionAcquireTimeoutError, con un síntoma característico y engañoso —latencia altísima sin CPU alta, porque todo el mundo está esperando—. Si lo ves, mira primero si hay consultas lentas reteniendo conexiones o transacciones abiertas que nadie cierra.

  1. V8 sin caer en el micro-optimizado inútil

V8 optimiza tu código mediante formas ocultas (hidden classes): objetos creados con la misma estructura comparten una descripción interna y el acceso a sus propiedades se compila a un desplazamiento fijo, rapidísimo. Si cambias la forma, V8 crea otra y el acceso pasa a ser polimórfico o megamórfico, mucho más lento.

'use strict';

// MAL: la forma del objeto cambia sobre la marcha (tres formas ocultas).
function crearEntradaMal(codigo, precioCentimos) {
  const entrada = {};
  entrada.codigo = codigo;
  entrada.precioCentimos = precioCentimos;
  if (precioCentimos === 0) entrada.esInvitacion = true; // solo a veces
  return entrada;
}

// BIEN: una unica forma, siempre los mismos campos y en el mismo orden.
const crearEntrada = (codigo, precioCentimos) => ({
  codigo, precioCentimos, esInvitacion: precioCentimos === 0,
});

// 'delete' degrada el objeto a modo diccionario y anula la optimizacion:
// en lugar de 'delete entrada.butaca', se reasigna.
const anularButaca = (entrada) => ({ ...entrada, butaca: null });

module.exports = { crearEntrada, anularButaca };

Y sobre memoria: el montículo antiguo tiene un límite (típicamente ~2 GB en 64 bits, o menos en contenedores pequeños) que se ajusta con --max-old-space-size=2048. Dos matices imprescindibles: subirlo no arregla una fuga, solo retrasa la muerte y alarga las pausas de la recolección de basura; y en un contenedor hay que ponerlo por debajo del límite de memoria del contenedor, porque si no el sistema mata el proceso (OOM killer) antes de que V8 se dé cuenta (detalle que retomaremos en el M11).

La advertencia general: estas micro-optimizaciones importan en código que se ejecuta millones de veces. En un controlador HTTP que se ejecuta 3 000 veces por segundo y pasa el 95 % del tiempo esperando a la base de datos, reordenar propiedades no cambia nada. Aplícalas solo si el perfil señala esa función.

  1. El orden correcto de las palancas

Cuando algo va lento hay un orden por retorno de la inversión:

Orden Palanca Ganancia típica Coste
1 Algoritmo y estructura de datos 10× a 1000× Pensar; a veces reescribir
2 Consulta y esquema (índice, N+1, SELECT de menos columnas) 10× a 100× Bajo; migración
3 Caché 5× a 50× Medio: invalidación (10-03)
4 Concurrencia (cluster, hilos, colas) 2× a 8× Medio-alto: estado compartido
5 Micro-optimización de código 1,05× a 1,5× Alto en legibilidad
6 Hardware Lineal, con factura mensual Dinero recurrente

Se recorre de arriba abajo. Comprar una máquina el doble de grande para tapar una consulta sin índice significa pagar el doble cada mes por un problema que se arreglaba con CREATE INDEX. Y así llegamos al recorrido real de Escena Viva en este módulo: primero concurrencia (10-01, 10-02), luego caché (10-03)... con el matiz honesto de que habría sido más eficiente empezar por el índice y la caché. Lo hicimos en el orden del temario por razones didácticas; en tu proyecto, sigue la tabla. Por último, qué se registra en producción para saber que todo sigue bien:

Métrica Umbral de alerta Por qué
p99 por ruta Por encima del objetivo de servicio 5 min Degradación real percibida
Tasa de 5xx > 0,5 % Algo se ha roto
Retraso p99 del bucle > 100 ms Trabajo síncrono colado
heapUsed Crecimiento monótono 30 min Fuga de memoria
Conexiones del pool en uso > 80 % del máximo Saturación inminente
Ratio de aciertos de caché < 90 % Invalidación demasiado agresiva o TTL corto
Trabajos en espera / fallidos Crecimiento sostenido Consumidores insuficientes o rotos

Recoger, almacenar y alertar sobre estas métricas es monitorización en producción, materia del módulo 11; aquí hemos aprendido a producirlas y a interpretarlas.

Errores Comunes y Consejos

  • Optimizar sin perfil. Es adivinar. En Escena Viva el sospechoso era el PDF y el culpable era el JSON.
  • Fijarse en la media. Optimiza el p99; es lo que la gente experimenta y recuerda.
  • Medir en desarrollo con datos de juguete. Con 3 eventos todo es rápido; con 30 000 aparece la consulta sin índice.
  • Prueba de carga con non2xx distinto de cero, o cambiar varias cosas a la vez: en el primer caso mides la velocidad de fallar, en el segundo no sabrás qué funcionó.
  • Perfilar en modo desarrollo. Sin NODE_ENV=production, Express no cachea vistas y hay comprobaciones extra: el perfil miente.
  • Subir --max-old-space-size para tapar una fuga. Retrasa la caída y empeora las pausas de la GC.
  • Consejo: guarda los resultados de la línea base en el repositorio; comparar contra el mes pasado detecta regresiones que ninguna prueba unitaria ve.
  • Consejo: añade una prueba de carga con umbral en CI (k6 con thresholds) y el rendimiento pasa de ser una opinión a un requisito verificable.

Ejercicios

Ejercicio 1: línea base y objetivos

Define una prueba de carga reproducible para GET /api/eventos/evt-003/sesiones con 10 s de calentamiento y 60 s de medición a 100 conexiones. Registra media, p50, p95, p99, req/s y non2xx. Compara con el objetivo de servicio de 200 ms y decide si hay que actuar.

Ejercicio 2: cazar el cuello con un flamegraph

Añade a Escena Viva un endpoint GET /api/informes/ocupacion que calcule la ocupación de las 7 sesiones ordenando el array de entradas con un algoritmo cuadrático deliberado. Perfila con 0x, localiza la caja ancha, sustituye por Array.prototype.sort y vuelve a medir.

Ejercicio 3: diagnosticar una fuga

Introduce una fuga registrando un oyente de venta-registrada en cada petición sin retirarlo. Lanza 30 000 peticiones registrando heapUsed cada 10 s. Toma dos instantáneas del montículo, compáralas y localiza el retenedor. Arregla y verifica.

Soluciones

Ejercicio 1. El escenario define warmup: { connections: 10, duration: 10 } y connections: 100, duration: 60. Resultado típico sin caché: media 96 ms, p50 84 ms, p95 210 ms, p99 340 ms, 1 030 req/s, non2xx: 0. El p99 de 340 ms incumple el objetivo de 200 ms, así que hay que actuar. La lectura fina es más interesante que el veredicto: la media (96 ms) parece cómoda y la mediana también; solo el p99 revela el problema. La causa habitual aquí es que las sesiones se cargan con una consulta por sesión (N+1): el arreglo es un include (M7) más caché (10-03), y tras aplicarlos el p99 baja a ~35 ms.

Ejercicio 2. En el flamegraph aparece una meseta ancha y plana sobre ordenarPorOcupacion, ocupando ~70 % del ancho, con muy poca profundidad encima: la señal inequívoca de que el tiempo se consume en esa función y no en sus llamadas. Con 7 sesiones el algoritmo cuadrático es irrelevante; el ejercicio revela su coste al hacerlo sobre las 1 811 entradas vendidas (~3,3 millones de comparaciones por petición). Sustituir por entradas.sort((a, b) => b.ocupacion - a.ocupacion) (O(n log n)) reduce el tiempo de la ruta de ~420 ms a ~6 ms. Es la palanca número 1 de la tabla —el algoritmo— y ninguna cantidad de cluster, hilos o caché habría dado un factor 70.

Ejercicio 3. heapUsed crece de forma monótona: 42 MB, 78 MB, 121 MB, 168 MB... sin estabilizarse nunca, ni siquiera tras una recolección forzada. Alrededor de la petición 10 000 aparece MaxListenersExceededWarning. En la comparación de instantáneas, el Delta muestra decenas de miles de objetos Closure y ServerResponse de más; los Retainers apuntan a GestorDeVentas._events['venta-registrada'], un array que crece sin fin. Cada cierre retiene respuesta, y con ella el socket y las cabeceras: por eso una fuga de "un oyente" cuesta kilobytes por petición. El arreglo es respuesta.on('close', () => gestor.off('venta-registrada', alVender)), y tras aplicarlo heapUsed se estabiliza en una sierra entre 45 y 70 MB: subida por asignación, bajada por recolección. Esa forma de sierra estable es exactamente el aspecto de un proceso sano.

Conclusión

Ya no optimizamos por intuición. Tenemos un método —medir, localizar, arreglar una cosa, volver a medir— y las cuatro señales que hay que vigilar, sabiendo que la media miente y que el p99 es lo que la gente experimenta. Sabemos diseñar una prueba de carga honesta con autocannon (calentamiento, duración, concurrencia y escenario realistas) y por qué jamás se lanza contra un sistema ajeno. Sabemos sacar un perfil de CPU con --prof, con el inspector o con 0x, y leer un flamegraph buscando cajas anchas y no torres altas. Sabemos cazar una fuga comparando instantáneas del montículo y mirando los retenedores. Y tenemos el retraso del bucle de eventos como la métrica de salud número uno. Además, hemos recorrido el catálogo de cuellos de botella de Node —E/S síncrona, JSON gigante, N+1, falta de índices, await secuencial, logging síncrono, ReDoS, datos cacheables— con su arreglo, hemos afinado la capa HTTP y el pool de conexiones, y hemos puesto las micro-optimizaciones de V8 en su sitio: al final de la lista, y solo si el perfil las señala. Sobre todo, tenemos el orden de las palancas: algoritmo, consulta, caché, concurrencia, hardware.

Escena Viva ya es rápida y está medida, así que lo que le queda pendiente no es rendimiento sino diseño. Su API creció por acumulación: rutas con verbos, códigos de estado usados a ojo, sin paginación por cursor, sin versionado, sin política de deprecación, y con una duda que dejamos abierta en el módulo 4 y que nunca resolvimos: qué pasa cuando un POST /api/pedidos se envía dos veces porque el usuario perdió la cobertura en el estreno. En la lección siguiente, Construcción de APIs RESTful, pasamos de "funciona" a "está bien diseñado".

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados