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/setInterval cuyo 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.log sincrónicas se ejecutan inmediatamente (1, 5).
  • setTimeout envía el callback a la cola de timers — se ejecuta en la siguiente fase.
  • Tanto process.nextTick como Promise.then son microtask, pero nextTick tiene mayor prioridad.
  • Después de que se ejecute el código sincrónico, Node.js agota la cola nextTick, luego la cola de microtask de Promise, 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 a nextTick), 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.nextTick recursivo: 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 setTimeout vs setImmediate al 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 monitorEventLoopDelay regularmente 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.

nodejsevent-loopjavascriptasincrónicobackend