Event Loop en Node.js: Mecanismo de funcionamiento y ejemplos prácticos
1. Introducción
El Event Loop es el mecanismo central que ayuda a Node.js a manejar miles de solicitudes simultáneamente utilizando solo un único thread para JavaScript. Muchos desarrolladores escriben código con async/await, setTimeout, Promise a diario pero no tienen claro cómo funciona realmente el orden de ejecución.
Este artículo explica la estructura del Event Loop a través de sus fases (phases) específicas, distingue entre microtask y macrotask, e ilustra con código ejecutable. Después de leerlo, serás capaz de predecir con precisión el orden de log de código asincrónico complejo y evitarás errores comunes como bloquear el event loop.
2. ¿Cómo funciona Node.js con un solo thread?
Node.js ejecuta JavaScript en un único thread principal (main thread), pero las tareas de I/O (lectura de archivos, llamadas de red, consultas a BD) se envían a libuv — una librería en C que maneja I/O asincrónico mediante un thread pool o mecanismos del kernel (epoll, kqueue…).
┌───────────────────────────┐
│ JavaScript (single thread) │
└──────────────┬────────────┘
│ llamar fs.readFile, setTimeout, net...
▼
┌───────────────────────────┐
│ libuv (C/C++) │
│ - Thread pool │
│ - Event loop │
│ - OS async APIs │
└───────────────────────────┘
Cuando se completa una tarea de I/O, libuv inserta el callback en la cola correspondiente. El Event Loop verifica continuamente estas colas y ejecuta el callback cuando le toca — eso es el “loop”.
Nota: El código JavaScript siempre se ejecuta en un thread. Si escribes código sincrónico pesado (por ejemplo, un loop de cálculos grandes), bloqueará todo el event loop, haciendo que todas las demás solicitudes esperen.
3. Las fases del Event Loop
El Event Loop no ejecuta un simple ciclo, sino que pasa por 6 fases en orden fijo cada iteración (tick):
┌───────────────────────┐
┌─>│ timers │ setTimeout, setInterval
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ pending callbacks │ callback de I/O demorado
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ idle, prepare │
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ poll │ eventos de I/O (lectura de archivo, network)
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ check │ setImmediate
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
└──┤ close callbacks │ socket.on('close')
└─────────────────────────┘
3.1. Descripción breve de cada fase
- timers: ejecuta los callbacks de
setTimeout/setIntervalcuyo plazo ha vencido. - pending callbacks: ejecuta los callbacks de I/O que fueron postponed del ciclo anterior.
- poll: espera y procesa nuevos eventos de I/O (lectura de archivo completada, respuesta de red recibida…). Esta es la fase que más tiempo consume.
- check: ejecuta los callbacks de
setImmediate. - close callbacks: maneja eventos de cierre, por ejemplo
socket.on('close', ...).
Cada fase tiene su propia cola (queue). El Event Loop procesa todos los callbacks en la cola de la fase actual (o hasta un límite) antes de pasar a la siguiente fase.
4. Cola de microtask: Promise y process.nextTick
Además de las 6 fases de macrotask anteriores, Node.js tiene una microtask queue, que se procesa entre cada fase — no espera a que se complete un ciclo completo del loop.
Hay dos tipos de microtask:
process.nextTick()— una cola separada, siempre se procesa antes que cualquier otro microtask.Promise.then/catch/finally— la cola de microtask estándar (según la especificación de ECMAScript).
console.log('1 - sync start');
setTimeout(() => console.log('2 - setTimeout'), 0);
Promise.resolve().then(() => console.log('3 - promise'));
process.nextTick(() => console.log('4 - nextTick'));
console.log('5 - sync end');
Explicación:
- Las líneas
console.logsincrónicas se ejecutan inmediatamente (1,5). setTimeoutenvía el callback a la cola de timers — se ejecuta en la siguiente fase.- Tanto
process.nextTickcomoPromise.thenson microtask, peronextTicktiene mayor prioridad. - Después de que se ejecute el código sincrónico, Node.js agota la cola
nextTick, luego la cola de microtask dePromise, antes de entrar en la fase de timers.
1 - sync start
5 - sync end
4 - nextTick
3 - promise
2 - setTimeout
Nota: Si llamas a
process.nextTick()de forma recursiva (por ejemplo, dentro de otro callback llamas nuevamente anextTick), el event loop nunca pasará a la siguiente fase — esto se llama “I/O starvation”.
5. setTimeout vs setImmediate: ¿cuál es el orden correcto?
Esta es la pregunta más confusa. El orden entre setTimeout(fn, 0) y setImmediate(fn) no es fijo si se llaman al nivel superior, pero sí es fijo si se llaman dentro de un callback de I/O.
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
Porque este código está dentro del callback de fs.readFile — es decir, en la fase poll — cuando poll termina, el Event Loop siempre cambia a la fase check (setImmediate) antes de que el siguiente ciclo vuelva a la fase timers.
immediate
timeout
Por el contrario, si pones ambas instrucciones al nivel superior (fuera de cualquier callback de I/O), el orden depende del rendimiento del proceso de inicio de Node.js en ese momento — no debes confiar en este orden en código de producción.
6. Bloquear el event loop: el error más común
Debido a que solo hay un thread ejecutando JavaScript, cualquier código sincrónico que se ejecute durante mucho tiempo bloquea todo el servidor.
const express = require('express');
const app = express();
app.get('/fibonacci', (req, res) => {
function fib(n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
res.send(`Result: ${fib(40)}`); // bloqueante!
});
app.listen(3000);
Cuando una solicitud llama a /fibonacci, la función fib(40) se ejecuta sincronamente, consumiendo varios segundos. Mientras tanto, todas las demás solicitudes (incluso un simple ping) deben esperar, porque el event loop está atrapado en una tarea.
6.1. Solución: empujar el trabajo pesado a Worker Thread
const { Worker } = require('worker_threads');
app.get('/fibonacci', (req, res) => {
const worker = new Worker('./fib-worker.js', {
workerData: { n: 40 }
});
worker.on('message', (result) => res.send(`Result: ${result}`));
worker.on('error', (err) => res.status(500).send(err.message));
});
// fib-worker.js
const { workerData, parentPort } = require('worker_threads');
function fib(n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
parentPort.postMessage(fib(workerData.n));
El Worker se ejecuta en su propio thread, sin ocupar el main thread. El event loop de la solicitud principal sigue libre para procesar otras solicitudes mientras el worker realiza el cálculo.
Cuándo NO necesitas Worker Thread: para tareas de I/O (consultar BD, llamar a API, leer archivos) — estas ya son asincrónicas por defecto, no necesitan worker. Worker Thread es útil solo para tareas CPU-bound (cálculos pesados, encriptación, procesamiento de imágenes…).
7. Medir el retraso del event loop con perf_hooks
Para detectar un event loop bloqueado en ambiente de producción, puedes usar el módulo perf_hooks integrado.
const { monitorEventLoopDelay } = require('perf_hooks');
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
console.log(`Event loop delay (mean): ${histogram.mean / 1e6} ms`);
histogram.reset();
}, 5000);
monitorEventLoopDelay crea un histogram que mide el tiempo que el event loop se “retrasa” respecto a su cronograma ideal. Si el valor mean sube inesperadamente (por ejemplo, arriba de 100ms), es señal de que hay código bloqueante en algún lugar.
8. Escribir pruebas para verificar el orden de ejecución
Para confirmar que has entendido correctamente el orden de microtask/macrotask, puedes escribir una prueba que registre los logs en un array y luego valide el orden.
// order.js
function recordOrder(log) {
log.push('sync-start');
setTimeout(() => log.push('timeout'), 0);
Promise.resolve().then(() => log.push('promise'));
process.nextTick(() => log.push('next-tick'));
log.push('sync-end');
}
module.exports = { recordOrder };
// order.test.js
const { recordOrder } = require('./order');
test('orden de ejecución: sync -> nextTick -> promise -> timeout', (done) => {
const log = [];
recordOrder(log);
// código sincrónico se ejecuta inmediatamente
expect(log).toEqual(['sync-start', 'sync-end']);
setImmediate(() => {
expect(log).toEqual([
'sync-start',
'sync-end',
'next-tick',
'promise',
'timeout',
]);
done();
});
});
Esta prueba usa Jest con setImmediate para esperar a que se ejecuten todos los microtask y la fase de timers, y luego valida el orden final de log. Esta es una forma más confiable de verificar tu comprensión del event loop en lugar de solo leer los logs.
npx jest order.test.js
PASS ./order.test.js
✓ orden de ejecución: sync -> nextTick -> promise -> timeout (5 ms)
9. Errores comunes
- Abuso de
process.nextTickrecursivo: causa I/O starvation, el servidor no puede procesar nuevas solicitudes. - Creer que
setTimeout(fn, 0)se ejecuta inmediatamente: en realidad siempre espera al menos un tick del event loop, y espera después de todos los microtask. - Ejecutar código CPU-bound en el main thread: por ejemplo, parsear JSON enorme, bcrypt con cost alto, procesar imágenes sincronamente — todo eso debería empujarse a Worker Thread o a un proceso hijo.
- Asumir el orden de
setTimeoutvssetImmediateal nivel superior: no es confiable, evita escribir lógica que dependa de esto.
Cuándo ser cuidadoso: si tu aplicación tiene endpoints que procesan datos grandes (exportar Excel, calcular estadísticas, redimensionar imágenes), mide
monitorEventLoopDelayregularmente para detectar problemas temprano antes de que afecten a todo el servidor.
10. Conclusión
El Event Loop es la razón por la que Node.js puede manejar una gran cantidad de conexiones con recursos limitados, pero viene con una limitación: todo el código JavaScript sincrónico se ejecuta en un único thread. Entender bien las 6 fases (timers, pending callbacks, poll, check, close) y la posición de la microtask queue (process.nextTick, Promise) te ayuda a predecir el orden correcto de ejecución y a evitar errores difíciles de depurar.
Cuando encuentres una tarea CPU-bound, no dudes en usar worker_threads o separar el trabajo en un proceso aparte — el event loop solo es potente cuando no lo ahogas.