Nota revisada y reescrita el 18 de octubre de 2026: la versión original tenía errores y datos sin fuente.
Node.js ejecuta tu JavaScript en un solo hilo. Si una petición hace un cálculo de 300 ms (un JSON.parse enorme, una regex mal escrita, un bucle sobre 100.000 filas), todas las demás peticiones esperan esos 300 ms. Por eso un servidor Node puede ir lento con la CPU de la máquina casi en reposo. Antes de optimizar nada, toca saber cuál de estas tres cosas pasa: event loop bloqueado, CPU saturada o memoria que crece.
1. ¿Está bloqueado el event loop?
perf_hooks trae un medidor del retraso del event loop. Es barato y se puede dejar en producción:
import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
const ms = (ns) => (ns / 1e6).toFixed(1);
console.log(`event loop p50=${ms(h.percentile(50))}ms p99=${ms(h.percentile(99))}ms max=${ms(h.max)}ms`);
h.reset();
}, 10_000);
Los valores están en nanosegundos. Con el event loop libre, los valores se quedan cerca de la resolution que le pasas (aquí, unos 20 ms: es la granularidad del medidor, no un retraso real). Si ves picos de cientos de milisegundos, algo síncrono está bloqueando el hilo. Para comprobarlo, este bucle lo bloquea a propósito unos 200 ms cada segundo:
setInterval(() => { const end = Date.now() + 200; while (Date.now() < end); }, 1000);
Con él activo, el max del medidor sube a unos 200 ms.
2. ¿Qué función se come la CPU?
Node puede generar un perfil de CPU sin instalar nada:
node --cpu-prof --cpu-prof-dir=./profiles server.js
# reproduce la carga (autocannon, k6 o tu propio script) y para el proceso con Ctrl+C
Al salir, escribe un archivo .cpuprofile. Ábrelo en Chrome DevTools (Performance → Load profile) o en VS Code y verás un flame chart con las funciones que más tiempo consumen. Busca las barras anchas de tu código, no las de Node.
Para mirarlo en vivo, arranca con node --inspect server.js, abre chrome://inspect y graba un perfil desde la pestaña Performance mientras lanzas peticiones. No dejes --inspect abierto en producción en una interfaz pública: da ejecución de código a quien se conecte.
Culpables habituales:
- Operaciones síncronas:
fs.readFileSync,crypto.pbkdf2Sync,zlib.gzipSyncdentro de un handler. JSON.parseoJSON.stringifyde respuestas de varios MB.- Expresiones regulares con backtracking catastrófico sobre la entrada del usuario.
- Bucles que recorren todo en memoria cuando debería filtrar la base de datos.
3. ¿Crece la memoria?
setInterval(() => {
const { heapUsed, rss } = process.memoryUsage();
console.log(`heap=${(heapUsed / 1e6).toFixed(0)}MB rss=${(rss / 1e6).toFixed(0)}MB`);
}, 30_000);
Si heapUsed sube sin parar con la misma carga, hay una fuga. El recolector de basura empieza a trabajar cada vez más y todo se ralentiza antes de que llegue el error de memoria. Para encontrarla, pide snapshots del heap con una señal:
node --heapsnapshot-signal=SIGUSR2 server.js
kill -USR2 <pid> # escribe un .heapsnapshot en el directorio de trabajo
Toma uno al arrancar y otro tras un rato de carga, y compáralos en DevTools (Memory → Comparison). Las fugas típicas son cachés en un Map sin límite, listeners que se añaden en cada petición y no se quitan, y closures que retienen objetos grandes.
4. Arreglos según el diagnóstico
- Event loop bloqueado por trabajo de CPU inevitable: muévelo a un
worker_threado a una cola con un proceso aparte, y usa las versiones asíncronas decryptoyzlib. - CPU saturada en general: primero el perfil. Después, varios procesos (cluster, o varias réplicas detrás del balanceador), porque un proceso Node solo usa un núcleo para tu JavaScript.
- Memoria: cachés con límite (LRU) y TTL, y
removeListeneroAbortSignalpara los listeners.