🔎 Buscar

⚙️ El event loop de JavaScript

Single-threaded, call stack, heap, Web/Node APIs, task queue vs microtask queue, Promises, async/await, libuv y el orden real de ejecución con ejemplos.

Wiki / Apuntes📖 Contenido

El event loop de JavaScript

JavaScript es single-threaded: ejecuta una sola cosa a la vez. Y sin embargo, un navegador no se congela mientras descarga datos. La magia es el event loop: un bucle que reparte el trabajo entre la pila de llamadas y las colas de tareas. Entenderlo es entender por qué setTimeout(0) no es inmediato y por qué tus Promises se resuelven «cuando menos lo esperas».

Single-threaded, pero no bloqueante

El motor (V8 en Chrome/Node) tiene tres piezas:

Pieza Qué es
Call stack La pila de funciones en ejecución (una a la vez)
Heap Memoria para objetos
Web/Node APIs setTimeout, fetch, addEventListener… provistas por el entorno, no por el motor

Cuando el call stack está vacío, el event loop mira si hay algo pendiente y lo ejecuta.

La pila de llamadas

function c() { return 3; }
function b() { return c(); }
function a() { return b(); }

a();

La ejecución entra a a, empuja b, empuja c, y va desempilando hacia atrás. El call stack es una pila LIFO: lo último en entrar es lo primero en salir.

⚠️ Stack overflow: una recursión sin caso base llena la pila y lanza RangeError: Maximum call stack size exceeded. El call stack es finito.

Las APIs asíncronas y las colas

setTimeout, fetch y los eventos no bloquean el stack: se registran en las Web/Node APIs y, cuando terminan, su callback va a una cola. El event loop solo ejecuta callbacks cuando el stack está vacío.

console.log("1");
setTimeout(() => console.log("2"), 0);
console.log("3");
// Salida: 1, 3, 2

¿Por qué el 2 al final aunque sea setTimeout(0)? Porque el callback no corre hasta que el stack se vacía y el loop lo recoge.

Task queue vs microtask queue

Hay dos colas con prioridades distintas:

Cola Prioridad Qué la llena
Microtask queue Alta (se vacía antes de la siguiente task) Promises (.then/catch/finally), queueMicrotask, await
Task queue (macrotask) Baja setTimeout, setInterval, I/O, eventos, setImmediate (Node)

El event loop, en cada vuelta: ejecuta una macrotask, y luego vacía por completo la microtask queue antes de la siguiente macrotask.

💡 Regla clave: las microtasks se vacían completamente después de cada macrotask y antes de cualquier otra. Por eso una Promise encadenada se resuelve antes que un setTimeout(0) que ya estaba en cola.

Promises: microtasks

console.log("A");

Promise.resolve().then(() => console.log("B"));
Promise.resolve().then(() => console.log("C"));

setTimeout(() => console.log("D"), 0);

console.log("E");
// Salida: A, E, B, C, D

A y E son síncronas (primero). Luego, al vaciar microtasks, corren B y C (Promise). D (setTimeout, macrotask) queda para el final.

async/await

async devuelve una Promise. await pausa la función, cede el control y se reanuda como una microtask cuando la promesa se resuelve:

async function run() {
  console.log("inicio de run");
  await Promise.resolve();
  console.log("después de await");
}

console.log("1");
run();
console.log("2");
// Salida: 1, inicio de run, 2, después de await

run() empieza síncrono hasta el primer await, luego devuelve el control (2), y el resto continúa como microtask.

Poniéndolo todo junto

console.log("1");                                    // síncrono

setTimeout(() => console.log("2"), 0);               // macrotask

Promise.resolve()
  .then(() => console.log("3"))                     // microtask
  .then(() => console.log("4"));                    // microtask

queueMicrotask(() => console.log("5"));             // microtask

console.log("6");                                    // síncrono

// Salida: 1, 6, 3, 4, 5, 2

Orden paso a paso:

  1. 1 y 6 corren síncronos.
  2. El stack se vacía → el loop vacía toda la microtask queue: 3, 4, 5.
  3. Recién entonces corre la macrotask 2.

libuv y el event loop en Node

En Node, el event loop lo implementa libuv. Tiene varias fases que se repiten en cada vuelta:

   ┌──────────────────────────┐
┌─►│         timers           │  setTimeout/setInterval
│  └────────────┬─────────────┘
│  ┌────────────┴─────────────┐
│  │     pending callbacks    │  callbacks de I/O
│  └────────────┬─────────────┘
│  ┌────────────┴─────────────┐
│  │      idle, prepare       │  interno
│  └────────────┬─────────────┘
│  ┌────────────┴─────────────┐
│  │          poll            │  espera I/O (¡la fase principal!)
│  └────────────┬─────────────┘
│  ┌────────────┴─────────────┐
│  │        check             │  setImmediate
│  └────────────┬─────────────┘
│  ┌────────────┴─────────────┐
│  │     close callbacks      │  cierres
│  └────────────┬─────────────┘
│        └──────┴──────────────┘
  • poll: la fase donde Node espera eventos de I/O (sockets, archivos) y ejecuta sus callbacks.
  • check: ejecuta setImmediate.
  • timers: ejecuta setTimeout/setInterval vencidos.

💡 Las microtasks (Promises) se vacían entre fase y fase y al final de cada callback. process.nextTick tiene una cola propia que corre antes que cualquier otra cosa, incluso antes de las Promises.

setTimeout vs setImmediate

// Ejecuta desde la línea de comandos (fuera del módulo)
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

El resultado no es determinista al arrancar, porque depende de en qué fase esté el loop. Pero dentro de un callback de I/O, setImmediate siempre gana:

const fs = require("node:fs");
fs.readFile(__filename, () => {
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});
// Salida: immediate, timeout  (determinista dentro de I/O)

Advertencias: no bloquees el loop

El event loop no hace el trabajo más rápido: aplaza el trabajo bloqueante, pero si el stack se llena de código síncrono pesado, todo lo demás se congela.

// ⚠️ MAL: bloquea el hilo durante segundos
const arr = Array(1e8);
arr.forEach((_, i) => arr[i] = heavyComputation(i));  // congela la UI

// ✅ BIEN: trocea el trabajo para dejar respirar al loop
async function processInChunks(arr, chunk = 10000) {
  for (let i = 0; i < arr.length; i += chunk) {
    // procesa un trozo
    await new Promise((r) => setTimeout(r, 0));  // cede al loop
  }
}

⚠️ No asumas que async hace el código mágicamente paralelo: las funciones asíncronas siguen ejecutándose en el mismo hilo. Un cálculo pesado dentro de un async sigue bloqueando. Para paralelismo real usa Web Workers (navegador) o worker_threads (Node).

Cheatsheet de orden

Qué ejecuta Orden
Código síncrono Siempre primero
process.nextTick Primero entre lo diferido
Promises / await / queueMicrotask Microtasks, antes de la siguiente task
setTimeout(0) / I/O / setImmediate Macrotasks, al final

Para profundizar

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.