⚙️ 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.
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:
1y6corren síncronos.- El stack se vacía → el loop vacía toda la microtask queue:
3,4,5. - 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/setIntervalvencidos.
💡 Las microtasks (Promises) se vacían entre fase y fase y al final de cada callback.
process.nextTicktiene 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
asynchace el código mágicamente paralelo: las funciones asíncronas siguen ejecutándose en el mismo hilo. Un cálculo pesado dentro de unasyncsigue 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
- MDN: Concurrency model and the event loop: la referencia clara del navegador.
- What the heck is the event loop anyway? (Philip Roberts): la charla clásica que lo hace visual.
- Node.js: The Node.js Event Loop (documentación oficial): fases de libuv en detalle.
- libuv design overview: cómo está construido el loop en C.
- JavaScript.info: Microtasks and the event loop: microtasks explicadas con ejemplos.
- Ruta completa: Frontend.