Kodokon kodokon.com

El event loop de Node: fases y trampas

Descubre las fases de libuv, la diferencia real entre setImmediate y setTimeout, y cómo detectar un event loop bloqueado.

10 min · 3 preguntas

Abrir esta lección en Kodokon

Node.js delega su event loop a libuv, que lo organiza en fases distintas: timers (setTimeout, setInterval), pending callbacks, idle/prepare (interna), poll (E/S: sockets, archivos), check (setImmediate) y close callbacks. En cada tick, el loop recorre estas fases en ese orden. Un detalle que pocos desarrolladores conocen: entre cada callback, Node primero vacía la cola de process.nextTick, luego las microtareas de promesas - y esto es así desde Node 11, después de cada callback en lugar de solo entre fases.

JAVASCRIPT
import { readFile } from "node:fs";

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

readFile(new URL(import.meta.url), () => {
  setTimeout(() => console.log("io timeout"), 0);
  setImmediate(() => console.log("io immediate"));
});
El orden solo está garantizado dentro de un callback de E/S.

Cuando arranca el script, el orden entre setTimeout(fn, 0) y setImmediate(fn) es no determinista: setTimeout(fn, 0) en realidad se ajusta a un mínimo de 1 ms, y si el loop llega a la fase de timers en menos de un milisegundo, el timer aún no es elegible. Dentro de un callback de E/S, en cambio, estás en la fase poll: la fase check (setImmediate) siempre viene antes de volver a la fase de timers. Por eso io immediate se imprime de forma consistente antes que io timeout.

JAVASCRIPT
setTimeout(() => {
  process.nextTick(() => console.log("nextTick"));
  Promise.resolve().then(() => console.log("promise"));
  queueMicrotask(() => console.log("microtask"));
  console.log("sync");
}, 0);
// sync, nextTick, promise, microtask
Dentro de un callback, nextTick se ejecuta antes que las microtareas.

Una trampa sutil: en el nivel superior de un módulo ES, este orden se invierte - las microtareas de promesas se ejecutan antes que process.nextTick. La razón: evaluar un módulo ES se ejecuta a su vez como un trabajo de promesa, por lo que la cola de microtareas ya se está vaciando cuando tu código síncrono termina. En CommonJS, nextTick conserva su prioridad incluso en el nivel superior. Verifica siempre este tipo de orden en el formato de módulo que realmente despliegas.

El event loop se ejecuta en un solo hilo: cualquier cálculo síncrono largo - un JSON.parse de un payload grande, una expresión regular catastrófica, un cifrado síncrono - congela todas las peticiones en curso. En producción, mide la latencia del loop con monitorEventLoopDelay: un p99 por encima de unas pocas decenas de milisegundos indica un bloqueo. El siguiente código provoca deliberadamente un bloqueo y luego lo mide.

JAVASCRIPT
import { monitorEventLoopDelay }
  from "node:perf_hooks";

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

const end = Date.now() + 200;
while (Date.now() < end) {}

setTimeout(() => {
  h.disable();
  const ms = h.max / 1e6;
  console.log(`max delay: ${ms.toFixed(1)} ms`);
}, 300);
El histograma revela el bloqueo de 200 ms.

Prueba de conocimientos

Comprueba que has retenido los puntos clave de esta lección.

  1. Dentro de un callback de E/S (por ejemplo fs.readFile), programas setTimeout(fn, 0) y setImmediate(fn). ¿Cuál se ejecuta primero?
    • setTimeout, siempre
    • setImmediate, siempre
    • El orden es aleatorio, como al arrancar el script
  2. Después de un callback, ¿en qué orden vacía Node las colas prioritarias?
    • Las microtareas de promesas van primero
    • Ambas colas se vacían en alternancia estricta
    • La cola de process.nextTick se vacía primero, luego las microtareas
  3. ¿Qué herramienta nativa mide con precisión los bloqueos del event loop en producción?
    • console.time alrededor de cada petición
    • monitorEventLoopDelay de node:perf_hooks
    • process.memoryUsage llamado dentro de un setInterval
    • el módulo node:trace_events