El event loop: cómo JavaScript maneja miles de tareas con un solo hilo

El event loop: cómo JavaScript maneja miles de tareas con un solo hilo

@programacion

Lo esencial

  • JavaScript es single-thread: ejecuta una sola línea de código a la vez, nunca dos en paralelo.
  • El event loop es el mecanismo que le permite atender miles de operaciones (red, disco, timers) sin bloquearse.
  • Las operaciones lentas no las hace JavaScript: las delega al navegador o a libuv (Node.js) y sigue trabajando.
  • Hay dos colas: microtasks (promesas) y macrotasks (setTimeout). Las microtasks siempre tienen prioridad.
  • Por eso Promise.then se ejecuta antes que setTimeout(fn, 0) aunque el timeout diga cero milisegundos.
  • Bloquear el hilo con un cálculo pesado congela TODO: ni clics ni red ni timers responden hasta que termine.
  • Entender el event loop explica el 90% de los bugs de orden de ejecución en JavaScript.

El problema que nadie te explica al empezar

Cuando alguien aprende JavaScript suele escuchar dos frases que parecen contradictorias: "JavaScript tiene un solo hilo" y "JavaScript es asíncrono y no se bloquea". ¿Cómo puede un lenguaje que solo hace una cosa a la vez descargar un archivo, esperar la respuesta de un servidor y responder a los clics del usuario al mismo tiempo?

La respuesta es el event loop (bucle de eventos). No es magia ni paralelismo real: es un sistema muy ingenioso para repartir el trabajo y no quedarse esperando de brazos cruzados. Entenderlo cambia por completo la forma en que escribes y depuras código.

La analogía: un mesero en un restaurante

Imagina un restaurante con un solo mesero. Si tomara la orden de la mesa 1 y se quedara parado frente a la cocina esperando a que el plato estuviera listo, las demás mesas se desesperarían: nadie sería atendido durante 20 minutos.

Un buen mesero no hace eso. Toma la orden de la mesa 1, la deja en la cocina y, mientras se cocina, va a atender a la mesa 2 y luego a la 3. Cuando la cocina toca la campana avisando que un plato está listo, el mesero lo recoge y lo lleva. El mesero es uno solo (un solo hilo), pero nunca se queda bloqueado esperando.

En esta analogía, el mesero es el hilo de JavaScript, la cocina es el navegador o el sistema operativo (que sí pueden hacer varias cosas a la vez) y la campana es el event loop avisando: "esto ya terminó, atiéndelo".

Las piezas: call stack, Web APIs y las colas

Por dentro, el motor de JavaScript trabaja con cuatro piezas que conviene conocer por su nombre:

  • Call stack (pila de llamadas) — donde se ejecuta tu código, una función encima de otra. Solo cabe una operación activa a la vez. Este es el "mesero".
  • Web APIs / libuv — las operaciones lentas (red, timers, lectura de disco) no las ejecuta JavaScript: las delega al navegador o, en Node.js, a una librería en C llamada libuv. Esta es la "cocina".
  • Callback queue (cola de macrotasks) — cuando una tarea delegada termina, su función de respuesta (callback) se pone a esperar aquí. Aquí caen los setTimeout, los eventos de clic, etc.
  • Microtask queue (cola de microtasks) — una cola especial y con prioridad para las promesas (.then, await) y queueMicrotask.

El event loop es la regla simple que coordina todo: "si la pila de llamadas está vacía, toma la siguiente tarea de la cola y ejecútala". Lo hace una y otra vez, millones de veces por segundo. La especificación exacta de este proceso está en el modelo de concurrencia de MDN.

El ejemplo que confunde a todos

Este fragmento es un clásico de las entrevistas técnicas. Antes de seguir leyendo, intenta adivinar en qué orden se imprimen los números:

console.log('1');

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

Promise.resolve().then(() => {
console.log('3');
});

console.log('4');

La intuición dice 1, 2, 3, 4. La realidad es 1, 4, 3, 2. Veamos por qué, paso a paso:

  • console.log('1') — código normal, se ejecuta de inmediato. Imprime 1.
  • setTimeout(..., 0) — JavaScript NO espera. Delega el timer al navegador y, cuando expira (incluso con 0 ms), pone el callback en la cola de macrotasks. Sigue de largo.
  • Promise.resolve().then(...) — la promesa ya está resuelta, así que su callback va a la cola de microtasks. Sigue de largo.
  • console.log('4') — código normal. Imprime 4.
  • Termina el código principal — la pila queda vacía. El event loop vacía PRIMERO todas las microtasks: imprime 3. Luego pasa a las macrotasks: imprime 2.

Microtasks contra macrotasks: la regla de prioridad

La clave del ejemplo anterior es que no todas las tareas pendientes son iguales. El event loop sigue una regla estricta: después de cada tarea, vacía por completo la cola de microtasks antes de tocar la siguiente macrotask.

  • Microtasks — promesas (.then, .catch, .finally), await, queueMicrotask y MutationObserver. Tienen prioridad máxima.
  • Macrotasks — setTimeout, setInterval, eventos del DOM, peticiones de red completadas, operaciones de I/O. Se atienden de una en una, y entre cada una se vacían todas las microtasks pendientes.

Por eso un setTimeout(fn, 0) no significa "ejecútame ahora mismo", sino "ejecútame en la próxima vuelta del bucle, después de todo lo que ya estaba en marcha". El 0 es un mínimo, no una garantía.

El peligro real: bloquear el hilo

Como solo hay un hilo, si tu código se queda haciendo un cálculo pesado de forma síncrona, nada más puede ejecutarse: ni los clics del usuario, ni las respuestas de red, ni los timers. La página se congela. Mira este error típico:

// MAL: esto congela la pestaña entera durante 5 segundos
function bloquear() {
const fin = Date.now() + 5000;
while (Date.now() < fin) {
// el hilo esta atrapado aqui, sin hacer nada util
}
}

bloquear();
console.log('Durante 5 segundos nadie pudo hacer clic en nada');

La solución no es "crear otro hilo" (JavaScript no lo permite en el flujo normal), sino partir el trabajo en pedazos que devuelvan el control al event loop, o mover el cálculo a un Web Worker en el navegador (un hilo aparte de verdad, que se comunica por mensajes). Para operaciones de entrada/salida, en cambio, no hay problema: la red y el disco ya son asíncronos por diseño.

async/await es el mismo motor con otra cara

Mucha gente cree que async/await es "otra forma" de hacer concurrencia. No lo es: es azúcar sintáctico sobre las promesas y, por tanto, sobre las microtasks. Cuando escribes await, en realidad estás diciendo "pausa esta función, deja que el event loop siga atendiendo lo demás y reanúdame cuando la promesa se resuelva".

async function cargarUsuario(id) {
console.log('pido los datos');
const res = await fetch('/api/users/' + id); // aqui cede el control
const data = await res.json(); // y aqui tambien
console.log('datos listos', data);
return data;
}

cargarUsuario(42);
console.log('esto se imprime ANTES de "datos listos"');

Mientras el fetch viaja por la red, el hilo no está bloqueado: el navegador se encarga de la petición y JavaScript sigue ejecutando lo que venga. En Node.js, el comportamiento de los timers y las fases del bucle se documenta en detalle en la guía oficial del event loop de Node.js.

Por qué esto importa en tu día a día

Entender el event loop no es trivia para entrevistas. Resuelve problemas concretos: por qué una animación tartamudea (estás bloqueando el hilo), por qué un estado se actualiza "un instante tarde" (orden de microtasks), por qué un setTimeout(0) no dispara cuando esperabas, o por qué un servidor Node.js aguanta miles de conexiones simultáneas con un consumo de memoria mínimo.

Es, además, el modelo que explica el éxito de Node.js en el backend: en lugar de abrir un hilo por cada cliente (caro en memoria), un solo hilo reparte el trabajo de entrada/salida y deja que el sistema operativo haga el resto. Para cargas dominadas por I/O —que son la mayoría en la web— es un modelo extraordinariamente eficiente.

Conclusión

El event loop es la respuesta a una pregunta aparentemente imposible: cómo un lenguaje de un solo hilo atiende miles de cosas a la vez. La idea es simple y elegante: no esperes nunca de brazos cruzados, delega lo lento y atiende cada resultado cuando esté listo, respetando la prioridad de las microtasks sobre las macrotasks.

Una vez que internalizas que JavaScript ejecuta tu código de principio a fin sin interrupciones, y que todo lo asíncrono espera su turno en una cola, el comportamiento del lenguaje deja de parecer caprichoso y empieza a tener una lógica perfectamente predecible. La próxima vez que un orden de ejecución te sorprenda, recuerda al mesero: él nunca se queda parado frente a la cocina.

Report Page