Entendiendo el Event Loop de JavaScript
El event loop es el motor detrás de cada operación asíncrona en JavaScript — así es como funciona realmente, más allá de las explicaciones superficiales.

La mayoría de los desarrolladores de JavaScript usan async/await y Promises a diario sin entender la maquinaria que hay debajo. Eso funciona hasta que te topas con un bug donde los callbacks se disparan en un orden inesperado, setTimeout(fn, 0) no se ejecuta de inmediato, o una microtarea le quita el aire al pipeline de renderizado. Entender el event loop convierte estos misterios en comportamiento predecible.
El call stack y la cola de tareas
JavaScript es de un solo hilo. Un call stack, una sola pieza de código ejecutándose a la vez. Cuando las operaciones asíncronas terminan, sus callbacks no interrumpen la ejecución actual — se suman a una cola y esperan.
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
console.log("3");
// Output: 1, 3, 2
// "2" goes to the task queue, runs after the current stack emptiesIncluso con un timeout de 0, el callback espera hasta que el call stack esté vacío. El trabajo del event loop es verificar: "¿Está vacío el stack? Si es así, toma la siguiente tarea de la cola."
Microtareas vs. macrotareas
No todo el trabajo en cola es igual. Las Promises y queueMicrotask van a la cola de microtareas. setTimeout, setInterval y los callbacks de I/O van a la cola de macrotareas. Las microtareas se vacían por completo antes de que se ejecute la siguiente macrotarea.
console.log("start");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise 1"));
queueMicrotask(() => {
console.log("microtask");
Promise.resolve().then(() => console.log("promise 2"));
});
console.log("end");
// Output: start, end, promise 1, microtask, promise 2, timeout// ❌ Assuming setTimeout and Promise.then have the same priority
function processItems(items: string[]) {
items.forEach((item) => {
setTimeout(() => sendToAnalytics(item), 0);
});
// Bug: if you resolve a Promise after this, it runs
// BEFORE any analytics calls
}
// ✅ Understanding the queue priority
function processItems(items: string[]) {
// Use the same queue type for ordering guarantees
items.forEach((item) => {
queueMicrotask(() => sendToAnalytics(item));
});
}El orden de prioridad por tick del event loop: vaciar todas las microtareas → ejecutar una macrotarea → vaciar todas las microtareas de nuevo → repetir.
El pipeline de renderizado
En los navegadores, el event loop se coordina con el motor de renderizado. Entre macrotareas, el navegador puede repintar — pero solo si la cola de microtareas está vacía.
// ❌ Microtask loop blocks rendering indefinitely
function recursiveMicrotask() {
queueMicrotask(() => {
// This starves the render pipeline — UI freezes
doExpensiveWork();
recursiveMicrotask();
});
}
// ✅ Use macrotasks to yield to the renderer
function chunkedWork(items: unknown[], index = 0) {
const CHUNK_SIZE = 100;
const end = Math.min(index + CHUNK_SIZE, items.length);
for (let i = index; i < end; i++) {
processItem(items[i]);
}
if (end < items.length) {
// setTimeout yields to the render pipeline between chunks
setTimeout(() => chunkedWork(items, end), 0);
}
}Si tu JavaScript bloquea el hilo principal por más de 16ms, te pierdes un frame. Los usuarios perciben esto como jank o congelamientos.
Node.js: fases adicionales
Node.js extiende el event loop del navegador con fases adicionales. El orden importa para el código del lado del servidor.
┌───────────────────────────┐
┌─→│ timers │ ← setTimeout, setInterval
│ └───────────┬───────────────┘
│ ┌───────────┴───────────────┐
│ │ pending callbacks │ ← I/O callbacks deferred
│ └───────────┬───────────────┘
│ ┌───────────┴───────────────┐
│ │ poll │ ← I/O events, incoming connections
│ └───────────┬───────────────┘
│ ┌───────────┴───────────────┐
│ │ check │ ← setImmediate
│ └───────────┬───────────────┘
│ ┌───────────┴───────────────┐
│ │ close callbacks │ ← socket.on('close')
│ └───────────┘───────────────┘
// In Node.js, setImmediate fires after I/O, setTimeout fires in timers phase
const fs = require("fs");
fs.readFile(__filename, () => {
// Inside an I/O callback:
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// Output: immediate, timeout (always in this order inside I/O)
});process.nextTick es especial — se ejecuta antes que cualquier otra microtarea, al final de la operación actual. Abusar de él puede dejar sin recursos a las operaciones de I/O.
Errores comunes
Bloqueo no intencional
// ❌ Synchronous file read blocks the event loop
import { readFileSync } from "fs";
app.get("/data", (req, res) => {
const data = readFileSync("/large-file.json", "utf-8");
res.json(JSON.parse(data));
});
// ✅ Async read lets the event loop handle other requests
import { readFile } from "fs/promises";
app.get("/data", async (req, res) => {
const data = await readFile("/large-file.json", "utf-8");
res.json(JSON.parse(data));
});Cadenas de promesas que crecen sin límite
// ❌ Creates a microtask chain that delays event processing
async function pollForever() {
while (true) {
await checkForUpdates();
// No yielding to macro tasks — this can delay timers
}
}
// ✅ Yield control between iterations
async function pollForever() {
while (true) {
await checkForUpdates();
await new Promise((resolve) => setTimeout(resolve, 100));
}
}Puntos clave
- Las microtareas siempre se ejecutan antes que la siguiente macrotarea — las Promises se disparan antes que
setTimeout(fn, 0) - Las colas de microtareas se vacían por completo antes de ceder el control, lo cual puede bloquear el renderizado
- Usa
setTimeoutpara ceder el control al pipeline de renderizado del navegador en trabajos de larga duración - Node.js tiene fases adicionales en el event loop —
setImmediateyprocess.nextTicktienen garantías de orden específicas - Nunca bloquees el event loop con I/O síncrono o cómputo intensivo de CPU en el hilo principal


