Por qué es importante el event loop
Node.js ejecuta tu JavaScript en un único hilo principal, y aun así, un solo servidor puede gestionar miles de conexiones simultáneas. Esto es posible porque el event loop nunca se detiene a esperar. Cuando una operación bloquearía el hilo, Node la delega al sistema operativo o a un thread pool y continúa con otras tareas. Cuando el resultado está listo, se encola un callback y el loop lo ejecuta.
Comprender el loop explica gran parte del comportamiento de Node: por qué una lectura de archivo síncrona congela todo el servidor, por qué una promesa se registra en el log antes que un temporizador de retraso cero y por qué el trabajo intensivo de CPU requiere un worker. Es el modelo mental más importante para escribir código rápido en Node.
El loop y sus fases
El event loop es un ciclo de fases. Cada fase tiene una cola de callbacks, y el loop los procesa antes de continuar con la siguiente.
- Timers — callbacks programados mediante
setTimeoutysetIntervalque ya han vencido. - Pending callbacks — callbacks del sistema diferidos, como algunos errores de TCP.
- Idle, prepare — tareas internas de mantenimiento.
- Poll — recupera nuevos eventos de I/O y ejecuta sus callbacks. Si no hay nada listo, puede bloquearse aquí brevemente.
- Check — ejecuta callbacks de
setImmediate. - Close callbacks — gestiona eventos como el cierre de sockets.
El loop continúa ciclando mientras haya trabajo pendiente. Cuando no quedan timers, no hay I/O pendiente ni handles, el proceso finaliza.
Microtareas: nextTick y promises
Entre fases, y después de cada callback, Node vacía dos colas de alta prioridad:
- Callbacks de
process.nextTick, que se ejecutan primero. - Callbacks de Promise, que se ejecutan después de nextTick.
Ambas se vacían completamente antes de que el loop avance, lo que tiene dos consecuencias. Primero, se ejecutan antes que los timers y los callbacks de I/O programados previamente. Segundo, un nextTick recursivo o una cadena de promises infinita pueden bloquear el loop (starve the loop), evitando que el I/O llegue a ejecutarse.
// order.js
console.log("1: synchronous");
setTimeout(() => console.log("5: timers"), 0);
setImmediate(() => console.log("6: check"));
Promise.resolve().then(() => console.log("3: promise"));
process.nextTick(() => console.log("2: nextTick"));
La salida es 1, 2, 3, luego 5 y 6. El orden de 5 y 6 puede variar en el nivel superior porque depende de cuánto tiempo tome llegar a la fase de timers; dentro de un callback de I/O, setImmediate siempre se ejecuta antes que setTimeout.
El thread pool de libuv
Algunas operaciones no pueden realizarse de forma asíncrona por el SO de manera portable: el acceso al sistema de archivos, las consultas DNS y algunas funciones criptográficas. libuv ejecuta estas tareas en un thread pool, que por defecto tiene cuatro hilos y es configurable mediante UV_THREADPOOL_SIZE.
Es por esto que fs.readFile no bloquea tu código, aunque internamente no esté basado en eventos. También significa que una ráfaga de operaciones de archivos puede quedar en cola detrás de un pool de cuatro hilos. Las API async mantienen el hilo principal libre; el pool se encarga de la espera.
Bloqueando el event loop
Cualquier operación síncrona y lenta en el hilo principal bloquea todo: otras solicitudes, timers y E/S. Los culpables más comunes incluyen:
readFileSyncy otras llamadas síncronas de archivos en los manejadores de solicitudes.JSON.parseoJSON.stringifyextensos en payloads grandes.- Criptografía síncrona, como
pbkdf2Synccon un número elevado de iteraciones. - Bucles cerrados, backtracking de regex y transformaciones de datos pesadas.
- Librerías bloqueantes que realizan trabajo de CPU en el hilo principal.
Mide en lugar de adivinar: el retraso del event loop (event loop delay) y la utilización del event loop (event loop utilization) te indican cuánto tiempo permanece bloqueado el loop. Un aumento en el retraso bajo carga es la señal clara.
Sacando el trabajo del loop
Cuando el trabajo depende genuinamente de la CPU (CPU-bound), muévelo:
- worker_threads para computación dentro del mismo proceso, mediante el paso de mensajes.
- child processes para tareas más pesadas o aisladas.
- Un servicio independiente para las cargas de trabajo más pesadas, escalado de forma independiente.
- Chunking: divide las tareas grandes en piezas más pequeñas y cede el control entre ellas.
Para I/O, utiliza las API async y streams para que el loop permanezca libre.
Mejores prácticas
- Utiliza APIs async en los request handlers; nunca bloquees el loop.
- Cede el control a I/O mediante
setImmediateen lugar denextTickrecursivos. - Mueve las tareas intensivas de CPU a worker threads o procesos hijos.
- Transmite datos grandes mediante streams en lugar de almacenarlos todos en memoria.
- Ajusta
UV_THREADPOOL_SIZEsolo después de realizar mediciones. - Monitorea el retraso y la utilización del event loop en producción.
- Mantén las cadenas de callbacks cortas para que las microtasks no bloqueen el I/O.
Errores comunes
- Usar
readFileSyncdentro de un hot path. process.nextTickrecursivos que bloquean el loop.- Asumir que Node.js es completamente single-threaded e ignorar el thread pool.
- Realizar cómputos pesados inline en lugar de delegarlos.
- Esperar que
setTimeout(fn, 0)se ejecute antes que los callbacks de I/O. - Ignorar el retraso del event loop hasta que aparecen problemas de latencia.
Próximos pasos
El event loop es el corazón de Node. Continúa con Streams para el I/O fragmentado y File System para el acceso asíncrono a archivos. Vuelve a consultar Node.js Basics si el runtime aún es nuevo para ti y, después, mide el retraso del event loop en un servicio que tengas en ejecución.