learning-front

Nivel 3 · JavaScript moderno y asíncrono

El event loop

La pila de llamadas y la cola de tareas: por qué JS no se bloquea y en qué orden corren las cosas.

En el capítulo de promesas pasó algo raro. Numeraste los mensajes 1, 2, 3… esperando que salieran en ese orden, pero la consola los soltó en otro: lo síncrono primero, y lo asíncrono después, cada cosa cuando le tocaba. Dijimos que el porqué exacto era el event loop. Ha llegado el momento: este capítulo es el modelo mental que explica por qué JavaScript no se bloquea y en qué orden corren las cosas. No introduce sintaxis nueva; te da las gafas para ver lo que ya estaba pasando.

En la teoría no hay dataset que valga: lo que importa es el ORDEN. En el ejercicio lo verás en su forma más cotidiana —una carga con caché y servidor donde el orden de los pasos sorprende— y aprenderás a predecirlo, que es justo lo que razonas al depurar asincronía.

Un solo hilo: la pila de llamadas#

Tu código JavaScript corre en un solo hilo. Es decir: hace una cosa cada vez. La estructura que lleva la cuenta de “qué está ejecutando ahora” es la pila de llamadas (call stack): cuando llamas a una función, se apila encima; cuando termina, se desapila.

javascript
function winrate(h) {
  // 3) calcula y desapila
  return h.victorias / h.partidas;
}
function resumen(h) {
  // 2) apila winrate, espera su resultado
  return h.nombre + ": " + winrate(h);
}
// 1) apila resumen
console.log(resumen({ nombre: "Tracer", victorias: 78, partidas: 120 }));

resumen se apila, llama a winrate (que se apila encima), winrate termina y se desapila, y resumen continúa. Una sola pila, una cosa a la vez. La consecuencia incómoda: si una función tarda mucho de forma síncrona (un bucle gigante), la pila se queda ocupada y nada más puede correr —ni un clic, ni una animación—. La página se congela.

Lo que tarda no se queda en la pila#

Entonces, ¿cómo es que un setTimeout que tarda no congela la página? Porque no se queda en la pila esperando. Cuando llamas a setTimeout(fn, 1000), la función setTimeout termina al instante (solo registra el encargo) y se desapila. El navegador se queda con el encargo “dentro de 1000 ms, ejecuta fn”, fuera de la pila. Cuando llega el momento, no mete fn en la pila a la fuerza: la deja esperando en una cola.

Lo mismo ocurre con cualquier operación que depende de la red (pedir datos a un servidor, por ejemplo). El navegador lanza la petición y la función se desapila; cuando llega la respuesta, la continuación espera en una cola. El hilo, mientras tanto, sigue libre para lo demás. Cómo se pide exactamente esa respuesta —y con qué función— es lo que aprenderás en el capítulo siguiente.

El event loop: vaciar la pila, coger de la cola#

El event loop es el mecanismo que une las dos piezas. Es un bucle incansable con una regla muy simple:

Cuando la pila de llamadas está vacía, coge lo siguiente de la cola y mételo en la pila.

Es decir: el código en cola nunca interrumpe al que está corriendo. Espera, educado, a que la pila se vacíe del todo. Por eso un setTimeout(fn, 0) no ejecuta fn “ahora”: lo pone en la cola, y fn tendrá que esperar a que termine todo el código síncrono que haya por delante.

Dos colas: microtareas y macrotareas#

Aquí está el matiz que lo explica todo. No hay una cola, hay dos, y una tiene prioridad:

  • La cola de macrotareas (o “cola de tareas”): ahí van los callbacks de setTimeout, setInterval y los eventos.
  • La cola de microtareas: ahí van las continuaciones de las promesas (.then, lo que sigue a un await) y lo que encolas con queueMicrotask.

La regla del event loop, afinada: cuando la pila se vacía, drena la cola de microtareas ENTERA (incluidas las microtareas que se generen por el camino) y solo entonces coge una macrotarea. En una frase: las microtareas (promesas) van siempre antes que las macrotareas (timers).

Para encolar una microtarea sin necesitar fabricar una promesa existe queueMicrotask(fn): mete fn directamente en la cola de microtareas, igual que haría Promise.resolve().then(fn), pero de forma más explícita y sin el envoltorio de promesa.

javascript
queueMicrotask(() => {
  // corre después de lo síncrono, antes de cualquier macrotarea
  console.log('Microtarea directa');
});

// Es equivalente a esto, pero más claro:
// Promise.resolve().then(() => console.log('Microtarea directa'));

El experimento canónico#

Junta las tres piezas —síncrono, microtarea, macrotarea— y predice el orden:

javascript
// síncrono: corre ya, en la pila
console.log("A");

// MACROtarea: a la cola de tareas
setTimeout(() => console.log("B"), 0);

// MICROtarea: a la cola de microtareas
Promise.resolve().then(() => console.log("C"));

// síncrono: corre ya, antes de vaciar la pila
console.log("D");

El orden es A, D, C, B:

  1. A y D son síncronos: corren en la pila, en orden, antes que nada.
  2. La pila se vacía. El event loop drena las microtareas: C (la promesa).
  3. Por fin, una macrotarea: B (el setTimeout).

Aunque el setTimeout se escribió antes que la promesa, la promesa gana: microtarea antes que macrotarea. Tenlo en el playground de abajo y juega con ello hasta que el orden deje de sorprenderte.

Vuelta al capítulo de promesas (y por qué await no bloquea)#

Ahora el misterio de antes tiene nombre. En el capítulo de promesas, el winrate (que tardaba 800 ms) imprimía después de los héroes (600 ms) porque su callback esperaba más en la cola. Y, en general, lo asíncrono salía tras lo síncrono porque el código en cola espera a que la pila se vacíe. No era azar: era el event loop.

Y la pregunta que cerramos: ¿por qué un await no congela la página? Porque await pausa solo su función y la saca de la pila; la continuación (lo que va después del await) se encola como microtarea para cuando la promesa se resuelva. Mientras tanto, el único hilo queda libre para atender clics, pintar y todo lo demás. Un hilo, sí, pero nunca parado.

Pruébalo tú#

Edita el código y mira la consola: confirma el orden 1, 2, 3, 4, 5 y entiende cada número. Luego añade un queueMicrotask(() => console.log('extra')) en cualquier punto y predice dónde caerá antes de ejecutarlo.

Comprueba lo que sabes#

Pregunta 1 de 5

¿Cuántas cosas ejecuta JavaScript a la vez con tu código?

Tu turno#

Implementa una carga con caché y servidor, y predice en qué orden salen sus logs antes de ejecutarla. La clave no es forzar el orden, sino entender el que emerge —y por qué una lectura síncrona justo después ve el dato viejo—. Cuando lo tengas (o si te atascas), despliega las soluciones y fíjate en cómo el nivel Excelente demuestra que la continuación de un await es una microtarea.

Ejercicio · en esta página

Predice el orden de una carga con caché

Cargas el perfil de un héroe con el patrón stale-while-revalidate: render inicial, luego la caché en memoria y, al final, el refresco del servidor. Implementa cada paso con su mecanismo natural (síncrono, microtarea, macrotarea) y predice en qué orden salen los logs. La clave no es forzar un orden, sino entender el que emerge —y por qué una lectura síncrona justo después ve el dato viejo—.

Paso 1: Que funcione

  • Los cuatro pasos loguean y el orden real es 1, 4, 2, 3 (síncronos, luego microtarea, luego macrotarea).
  • Observas que el paso 4 ve el héroe aún sin datos: la caché todavía no se ha aplicado.
Ver soluciones
// ════════════════════════════════════════════════════════════════════════════
// NIVEL OK — "funciona"
//
// Implementa los cuatro pasos con su mecanismo natural y observa el orden REAL en
// la consola:
//   1 · render inicial   (síncrono)
//   4 · fin síncrono     (síncrono)
//   2 · caché aplicada   (microtarea)
//   3 · servidor         (macrotarea)
// Es decir: 1, 4, 2, 3. Y ojo al paso 4: ve heroe = null, porque la caché (paso 2)
// es una microtarea que aún NO ha corrido cuando se ejecuta el síncrono.
//
// Su límite: funciona y se observa el orden, pero no explica POR QUÉ sale así.
// ════════════════════════════════════════════════════════════════════════════
// valor en caché (viejo)
const cacheHeroe = { nombre: "Tracer", winrate: 0.62 };
// valor fresco del servidor
const servidorHeroe = { nombre: "Tracer", winrate: 0.65 };

// lo que mostraríamos ahora mismo (aún sin datos)
let heroe = null;

// PASO 1 — render inicial (síncrono): corre ya, en la pila.
console.log("1 · render inicial · heroe = " + heroe);

// PASO 2 — caché (microtarea): el .then de una promesa resuelta va a la cola de microtareas.
Promise.resolve(cacheHeroe).then((h) => {
  // aplicamos el valor de caché
  heroe = h;
  console.log("2 · caché aplicada · " + h.nombre);
});

// PASO 3 — servidor (macrotarea): el callback de setTimeout va a la cola de tareas.
setTimeout(() => {
  // refrescamos con el dato fresco
  heroe = servidorHeroe;
  console.log("3 · servidor · " + servidorHeroe.nombre);
}, 0);

// PASO 4 — comprobación síncrona: corre ANTES de vaciar la pila, así que heroe sigue null.
console.log("4 · fin del trozo síncrono · heroe = " + heroe);

Por qué este nivel

  • Implementa los cuatro pasos y observa el orden real en la consola: 1, 4, 2, 3 (síncronos primero, luego la microtarea de la caché, luego la macrotarea del servidor).
  • Su límite: funciona y se ve el orden, pero no explica por qué sale así.