El event loop en JavaScript: call stack, microtasks y macrotasks

JavaScript es single-threaded: solo puede ejecutar una cosa a la vez. Sin embargo, puede gestionar operaciones de I/O, temporizadores y eventos sin bloquearse gracias al event loop, que coordina el call stack con una cola de tareas pendientes. Entender este mecanismo explica por qué el código asíncrono se ejecuta en el orden que se ejecuta.

El call stack

El call stack (pila de llamadas) es donde JavaScript registra en qué función está ejecutando en cada momento. Cuando llamas a una función, se apila; cuando termina, se desapila:

function c() {
  console.log('c ejecutando');
  // Stack: [main, a, b, c]
}

function b() {
  console.log('antes de c');
  c();
  console.log('después de c');
  // Stack: [main, a, b]
}

function a() {
  b();
  // Stack: [main, a]
}

a();
// Orden de salida:
// 'antes de c'
// 'c ejecutando'
// 'después de c'

Macrotasks y microtasks

No todas las tareas asíncronas tienen la misma prioridad. Las microtasks (callbacks de Promises, queueMicrotask, MutationObserver) tienen prioridad sobre las macrotasks (callbacks de setTimeout, setInterval, eventos del DOM). El event loop siempre procesa todas las microtasks pendientes antes de ejecutar la siguiente macrotask:

console.log('1: síncrono');

setTimeout(() => console.log('2: setTimeout (macrotask)'), 0);

Promise.resolve()
  .then(() => console.log('3: promise .then (microtask)'))
  .then(() => console.log('4: segundo .then (microtask)'));

queueMicrotask(() => console.log('5: queueMicrotask'));

console.log('6: síncrono');

// Orden de salida:
// 1: síncrono
// 6: síncrono
// 3: promise .then (microtask)    ? microtasks primero
// 4: segundo .then (microtask)    ? incluso los encadenados
// 5: queueMicrotask               ? también microtask
// 2: setTimeout (macrotask)       ? macrotask al final

Por qué setTimeout(fn, 0) no es inmediato

setTimeout(fn, 0) no ejecuta el callback en el siguiente tick: lo pone en la cola de macrotasks. El callback no se ejecutará hasta que el call stack esté vacío y todas las microtasks pendientes hayan terminado:

function ejemplo() {
  console.log('inicio');

  setTimeout(() => {
    console.log('timeout');
  }, 0);

  // Este bucle "ocupa" el call stack durante 1 segundo
  const limite = Date.now() + 1000;
  while (Date.now() < limite) {}

  console.log('fin');
}

ejemplo();
// 'inicio'
// 'fin'        ? después del bucle
// 'timeout'    ? solo cuando el call stack se vacía

Ejemplo completo: el orden de ejecución

console.log('A');  // Síncrono

setTimeout(() => console.log('B'), 0);  // Macrotask

new Promise(resolve => {
  console.log('C');  // Síncrono (el ejecutor es síncrono)
  resolve();
})
.then(() => {
  console.log('D');  // Microtask
  setTimeout(() => console.log('E'), 0);  // Macrotask (desde microtask)
})
.then(() => console.log('F'));  // Microtask

console.log('G');  // Síncrono

// Orden: A, C, G, D, F, B, E
//
// Fase 1 - Stack síncrono: A, C (constructor Promise), G
// Fase 2 - Microtasks:     D (then 1), F (then 2 encadenado)
//   D también programa macrotask E
// Fase 3 - Macrotasks:     B (primer setTimeout), E (programado desde D)

El event loop con I/O

Las operaciones de red o disco (fetch, fs.readFile en Node) no bloquean porque el motor las delega al entorno (navegador/OS). Cuando terminan, el callback se pone en la cola de macrotasks:

console.log('inicio');

fetch('/api/datos')
  .then(r => r.json())
  .then(datos => {
    // Este callback llega a la cola de microtasks cuando la Promise
    // interna de fetch se resuelve (cuando llega la respuesta de red)
    console.log('datos recibidos:', datos);
  });

console.log('fin del código síncrono');

// 'inicio'
// 'fin del código síncrono'
// ... (tiempo de red) ...
// 'datos recibidos: ...'

Entender el event loop explica por qué las Promises tienen prioridad sobre setTimeout, por qué no debes hacer bucles síncronos largos (bloquean todo), y por qué en Node.js el código I/O no bloquea aunque el lenguaje sea single-threaded. Es también la base para entender herramientas como requestAnimationFrame y los schedulers de frameworks como React.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP