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.
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,setIntervaly los eventos. - La cola de microtareas: ahí van las continuaciones de las promesas (
.then, lo que sigue a unawait) y lo que encolas conqueueMicrotask.
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.
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:
// 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:
- A y D son síncronos: corren en la pila, en orden, antes que nada.
- La pila se vacía. El event loop drena las microtareas: C (la promesa).
- 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.
Paso 2: Que esté pulido
- Explicas en comentarios por qué ese orden, desde el modelo: pila → microtareas → macrotareas.
- Dejas claro por qué la caché (microtarea) gana al servidor (macrotarea) aunque su valor ya estuviera listo.
Paso 3: Que sea excelente
- Demuestras que la continuación de un await es una microtarea (sale junto a la caché, no con el servidor).
- Conectas el paso 4 con el bug real: leer un estado justo después de programar su actualización async devuelve el valor viejo.
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í.
// ════════════════════════════════════════════════════════════════════════════
// NIVEL MEJOR — "pulido"
//
// Por qué mejora a OK: explica, desde el modelo, POR QUÉ el orden es 1, 4, 2, 3.
// - 1 y 4 son SÍNCRONOS: corren en la pila, en orden de línea, antes que nada.
// - 2 (caché) es una MICROtarea: aunque el valor esté listo al instante, entregarlo
// con un .then lo aplaza a la cola de microtareas, que se drena al vaciarse la pila.
// - 3 (servidor) es una MACROtarea: se atiende después de TODAS las microtareas.
// Y el detalle clave: el paso 4 ve heroe = null porque la microtarea de la caché
// todavía no ha corrido cuando el síncrono llega a esa línea.
//
// Su límite respecto a Excelente: no toca el matiz del `await` ni lo conecta con el
// bug de asincronía que de verdad depuras en empresa.
// ════════════════════════════════════════════════════════════════════════════
// 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;
// 1 — SÍNCRONO: corre en la pila, en orden de línea. Sale el primero.
console.log("1 · render inicial · heroe = " + heroe);
// 2 — MICROtarea: el .then se encola en microtareas; corre al vaciarse la pila,
// antes que cualquier macrotarea, aunque el valor de la caché ya estuviera listo.
Promise.resolve(cacheHeroe).then((h) => {
heroe = h;
console.log("2 · caché aplicada · " + h.nombre);
});
// 3 — MACROtarea: el setTimeout se encola en tareas; se atiende tras drenar TODAS
// las microtareas. Por eso el servidor sale el último, aunque su retardo sea 0.
setTimeout(() => {
heroe = servidorHeroe;
console.log("3 · servidor · " + servidorHeroe.nombre);
}, 0);
// 4 — SÍNCRONO: corre todavía en la pila, antes de la microtarea de la caché.
// Por eso aquí heroe sigue siendo null: la caché aún no se ha aplicado.
console.log("4 · fin del trozo síncrono · heroe = " + heroe); Por qué es mejor que el anterior
- Explica desde el modelo por qué el orden es 1, 4, 2, 3: lo síncrono en orden de línea, la caché como microtarea tras vaciar la pila, el servidor (macrotarea) el último.
- Aclara el detalle clave: el paso 4 ve el héroe en null porque la microtarea de la caché aún no ha corrido.
- Su límite: no toca el matiz del await ni lo conecta con el bug real de empresa.
// ════════════════════════════════════════════════════════════════════════════
// NIVEL EXCELENTE — "óptimo"
//
// Por qué mejora a Mejor:
// - DEMUESTRA (no solo afirma) un matiz real: la continuación de un `await` también
// es una MICROtarea, igual que un .then. La versión con await (2b) sale en la
// MISMA cola que la caché —antes que el servidor—, no en la macrotarea.
// - Deja ver que varias microtareas se drenan TODAS antes de la primera macrotarea:
// 2 y 2b salen seguidas, y solo después el servidor (3).
// - Conecta con el bug de asincronía nº1 en empresa: leer un estado JUSTO DESPUÉS de
// programar su actualización async (el paso 4) devuelve el valor viejo. Es el
// "pero si ya lo actualicé / por qué sale el dato anterior" que depuras una y otra vez.
//
// Orden resultante: 1, 4 (síncronos) · 2, 2b (microtareas) · 3 (macrotarea).
// ════════════════════════════════════════════════════════════════════════════
// 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;
// 1 — SÍNCRONO: primero, en la pila.
console.log("1 · render inicial · heroe = " + heroe);
// 2 — MICROtarea (vía .then): se encola en microtareas.
Promise.resolve(cacheHeroe).then((h) => {
heroe = h;
console.log("2 · caché aplicada · " + h.nombre);
});
// 2b — MICROtarea (vía await): lo que va DESPUÉS de un await se encola como microtarea,
// igual que un .then. Por eso 2b sale junto a la caché, no con el servidor.
(async () => {
// la promesa ya está resuelta…
const h = await Promise.resolve(cacheHeroe);
// …pero la continuación (esto) corre como microtarea, no en la misma línea.
console.log("2b · await (misma cola que la caché) · " + h.nombre);
})();
// 3 — MACROtarea: se atiende tras drenar TODAS las microtareas (2 y 2b).
setTimeout(() => {
heroe = servidorHeroe;
console.log("3 · servidor · " + servidorHeroe.nombre);
}, 0);
// 4 — SÍNCRONO: aquí heroe sigue null. Leer el estado justo después de programar su
// actualización async devuelve el valor viejo: el bug de asincronía más repetido.
console.log("4 · fin del trozo síncrono · heroe = " + heroe); Por qué es mejor que el anterior
- Demuestra que la continuación de un await es una microtarea: sale junto a la caché, antes del servidor, no con la macrotarea.
- Deja ver que todas las microtareas se drenan antes de la primera macrotarea.
- Conecta con el bug nº1 de asincronía: leer un estado justo después de programar su actualización async devuelve el valor viejo.