El Event Loop de JavaScript: cómo funciona y por qué te importa
Desentrañamos el Event Loop paso a paso — call stack, Web APIs, task queue y microtask queue — para que puedas predecir qué hace tu código y por qué se ejecuta en ese orden.
Hay un tipo de preguntas en entrevistas de JavaScript que parece diseñado para hacerte tropezar: te muestran un bloque de código con setTimeout, una Promise y un par de console.log, y te preguntan “¿qué imprime y en qué orden?”.
La primera vez que nos cayó una así, tiramos una respuesta por intuición y nos equivocamos. Después investigamos cómo funciona realmente el Event Loop y todo empezó a tener sentido — no solo para responder preguntas de entrevista, sino para entender bugs de timing, problemas de performance y comportamientos que parecían aleatorios.
¿Por qué JavaScript necesita un Event Loop?
JavaScript es single-threaded. Un solo hilo de ejecución. No puede pausar una función a la mitad, hacer otra cosa y volver. Todo lo que se ejecuta en el hilo principal lo hace de forma secuencial.
Pero JavaScript necesita hacer muchas cosas que toman tiempo: peticiones HTTP, timers, acceso a bases de datos (en Node.js), interacción con el usuario. Si esperara sincronicamente a que cada una termine, la UI se congelaría en cada request y Node.js podría manejar un solo usuario a la vez.
La solución es un modelo de concurrencia basado en el Event Loop: delegás el trabajo costoso a otra parte (Web APIs en el navegador, libuv en Node.js) y cuando ese trabajo termina, un callback se encola para que lo procese el hilo principal cuando esté libre.
Las piezas del sistema
Entender el Event Loop requiere entender cuatro componentes que trabajan juntos:
1. El Call Stack
Es donde JavaScript ejecuta código. Cada vez que llamás a una función, se agrega un “frame” al stack. Cuando la función retorna, se saca.
function tercera() {
console.log("tres");
}
function segunda() {
tercera();
console.log("dos");
}
function primera() {
segunda();
console.log("uno");
}
primera();
// Stack: primera → segunda → tercera
// Output: "tres", "dos", "uno"
El stack es LIFO (Last In, First Out). Lo último que entra es lo primero que sale. Mientras haya código en el stack, nada más se puede ejecutar.
2. Las Web APIs (o el runtime externo)
Cuando llamás a algo asíncrono — setTimeout, fetch, addEventListener — JavaScript no se queda esperando. Le pasa la tarea al entorno (el navegador o Node.js) y sigue ejecutando la siguiente línea.
console.log("primero");
setTimeout(() => {
console.log("tercero"); // ejecutado por Web API → callback encolado
}, 1000);
console.log("segundo");
// Output: "primero", "segundo", (1 segundo) "tercero"
Lo que pasa internamente:
console.log("primero")→ Call Stack → ejecuta → sale del stacksetTimeout(fn, 1000)→ Call Stack → registra timer en Web API → sale del stackconsole.log("segundo")→ Call Stack → ejecuta → sale del stack- (1000ms después) → Web API pone
fnen la Task Queue - Event Loop ve stack vacío → mueve
fnal stack → ejecuta
3. La Task Queue (Macrotask Queue)
Acá se encolan los callbacks de:
setTimeout/setInterval- I/O callbacks (lectura de archivos, respuestas de red)
- Eventos del DOM
setImmediate(solo Node.js)
El Event Loop toma un callback de la Task Queue cada vez que el Call Stack está vacío.
4. La Microtask Queue
Las microtasks tienen mayor prioridad que las macrotasks. Se procesan todas las microtasks pendientes antes de pasar a la siguiente macrotask. Acá van:
- Callbacks de
.then()/.catch()/.finally() queueMicrotask(fn)MutationObserverprocess.nextTick(Node.js, incluso más prioritario que las promesas)
Esta diferencia de prioridad es la clave para predecir el orden de ejecución.
El flujo completo del Event Loop
┌──────────────────────────┐
│ Call Stack │ ← Ejecuta lo que tiene
└────────────┬─────────────┘
│ ¿Vacío?
▼
┌──────────────────────────┐
│ Microtask Queue │ ← Se vacía COMPLETAMENTE
│ (promesas, nextTick) │ antes de seguir
└────────────┬─────────────┘
│ ¿Vacía?
▼
┌──────────────────────────┐
│ Task Queue │ ← Se toma UNA macrotask
│ (setTimeout, I/O, DOM) │ y se vuelve arriba
└──────────────────────────┘
Cada ciclo del Event Loop:
- Ejecuta todo lo que hay en el Call Stack
- Procesa todas las microtasks
- Toma una macrotask de la queue
- Repite
El ejemplo que lo demuestra
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
Antes de leer la respuesta, pensá qué se imprime. En serio, vale la pena hacer el ejercicio mental.
Output: 1, 4, 3, 2
Paso a paso:
console.log("1")→ stack, ejecuta. Output: 1setTimeout(fn, 0)→ callback registrado en Web API → macrotask queuePromise.resolve().then(fn)→ la promesa ya está resuelta → callback a microtask queueconsole.log("4")→ stack, ejecuta. Output: 4- Stack vacío → revisa microtask queue → ejecuta el
.then(). Output: 3 - Microtask queue vacía → revisa macrotask queue → ejecuta el
setTimeout. Output: 2
El setTimeout(fn, 0) no significa “ejecutar inmediatamente”. Significa “ejecutar lo antes posible, pero después de que el stack y las microtasks se vacíen”. En la práctica, nunca es realmente 0ms.
Un ejemplo más complejo
console.log("A");
setTimeout(() => {
console.log("B");
Promise.resolve().then(() => console.log("C"));
}, 0);
Promise.resolve().then(() => {
console.log("D");
setTimeout(() => console.log("E"), 0);
});
console.log("F");
Output: A, F, D, B, C, E
¿Por qué?
"A"— síncronosetTimeout(B...)— registrado como macrotaskPromise.then(D...)— microtask"F"— síncrono- Stack vacío → microtask:
"D", que registrasetTimeout(E...)como nueva macrotask - Microtask queue vacía → macrotask:
"B", que crea una microtask ("C") - Antes de la próxima macrotask: microtask
"C" - Siguiente macrotask:
"E"
Si lograste seguir eso, ya entendés el Event Loop más que la mayoría de los desarrolladores.
Cuando bloqueás el Event Loop
Si ponés código síncrono pesado en el hilo principal, bloqueás todo: UI, callbacks, microtasks. Nada se procesa hasta que termine.
// ❌ Esto congela la UI
document.getElementById("btn").addEventListener("click", () => {
let sum = 0;
for (let i = 0; i < 2_000_000_000; i++) {
sum += i;
}
console.log(sum);
});
Mientras ese loop corre, el botón no responde, las animaciones se pausan, otros event handlers no se ejecutan. El usuario ve la pestaña congelada.
Soluciones según el caso:
Web Workers: para cálculos pesados que necesitan un hilo separado.
// main.js
const worker = new Worker("./worker.js");
worker.postMessage({ iterations: 2_000_000_000 });
worker.onmessage = (e) => console.log("Resultado:", e.data);
// worker.js
self.onmessage = (e) => {
let sum = 0;
for (let i = 0; i < e.data.iterations; i++) sum += i;
self.postMessage(sum);
};
Chunking con setTimeout: para dividir trabajo pesado en partes sin bloquear.
function procesarEnChunks(datos, chunkSize, procesarItem) {
let i = 0;
function procesarChunk() {
const fin = Math.min(i + chunkSize, datos.length);
for (; i < fin; i++) {
procesarItem(datos[i]);
}
if (i < datos.length) {
setTimeout(procesarChunk, 0); // devuelve control al Event Loop
}
}
procesarChunk();
}
requestIdleCallback: para tareas de baja prioridad que se ejecutan cuando el browser está libre.
El caso de requestAnimationFrame
requestAnimationFrame (rAF) tiene su propio timing dentro del Event Loop. Se ejecuta justo antes de que el navegador repinte la pantalla, después de las microtasks pero antes del rendering.
function animar(timestamp) {
// Mover elemento, actualizar canvas, etc.
elemento.style.transform = `translateX(${timestamp / 10}px)`;
requestAnimationFrame(animar);
}
requestAnimationFrame(animar);
Si necesitás animaciones suaves, rAF es la opción correcta. setInterval(fn, 16) puede driftear y no se sincroniza con el repaint del navegador.
Node.js: misma idea, diferente implementación
En Node.js el Event Loop usa libuv y tiene fases más granulares:
- timers — ejecuta callbacks de
setTimeout/setIntervalcuyo tiempo expiró - pending callbacks — I/O callbacks diferidos del ciclo anterior
- idle / prepare — uso interno de Node
- poll — espera nuevos eventos de I/O
- check — ejecuta callbacks de
setImmediate - close callbacks —
socket.on('close', ...), etc.
Dos particularidades:
process.nextTick(fn)se ejecuta antes que cualquier microtask, al final de la operación actual. Es incluso más prioritario que.then().setImmediate(fn)se ejecuta en la fase “check”, que es después de I/O. Si estás dentro de un callback de I/O,setImmediatese ejecuta antes quesetTimeout(fn, 0).
const fs = require("fs");
fs.readFile("archivo.txt", () => {
setTimeout(() => console.log("setTimeout"), 0);
setImmediate(() => console.log("setImmediate"));
});
// Dentro de un callback de I/O: "setImmediate" siempre primero
Fuera de I/O, el orden entre setTimeout(fn, 0) y setImmediate no es determinístico. Si te importa el orden, usá setImmediate o process.nextTick explícitamente.
Lo que esto implica en la práctica
Entender el Event Loop no es un ejercicio académico. Tiene consecuencias directas en cómo escribís código:
- ¿Tu endpoint de Express se cuelga con muchos usuarios? Puede que estés bloqueando el loop con una operación síncrona pesada (procesamiento de imágenes, parsing de archivos grandes).
- ¿Un
setStateen React no se refleja inmediatamente? Porque React bacha actualizaciones usando microtasks. - ¿
setTimeout(fn, 0)no se ejecuta “inmediatamente”? Porque primero se vacían las microtasks pendientes. - ¿Un
setIntervalse desincroniza con el tiempo? Porque el callback no se ejecuta exactamente cada N milisegundos, sino cuando el Event Loop llega a él.
Para llevar
El Event Loop es el mecanismo que le permite a JavaScript hacer concurrencia sin hilos múltiples. No es complicado una vez que internalizás las reglas:
- El Call Stack se vacía primero, siempre
- Las microtasks se procesan todas antes de cualquier macrotask
- Se toma una macrotask por ciclo
- Código síncrono pesado bloquea todo lo demás
La próxima vez que veas un setTimeout(fn, 0), ya sabés: no es “ejecutar ahora”, es “ejecutar lo antes posible después de todo lo que ya está en cola”. Y cuando alguien te muestre un ejercicio de orden de ejecución en una entrevista, vas a poder razonarlo en lugar de adivinarlo.