La lección anterior terminó con una pregunta abierta: sabes qué hace await, pero no por qué el orden de la salida es el que es. Por qué un console.log del nivel superior aparece después de haber lanzado tres operaciones asíncronas; por qué un .then sobre una promesa ya cumplida se ejecuta más tarde que el código que va debajo, pero antes que un setTimeout(f, 0) registrado mucho antes; por qué un bucle pesado congela la aplicación aunque el código esté lleno de await. Todo eso lo decide una maquinaria que hasta ahora hemos descrito con la mano: el bucle de eventos. En esta lección la vas a ver entera —la pila de llamadas, las APIs del entorno, las dos colas y el algoritmo que las coordina—, vas a predecir la salida de fragmentos que parecen imposibles y a trazarlos paso a paso, y vas a entender exactamente qué congela una interfaz y qué no. Es la pieza que convierte la asincronía de «funciona por magia» en «funciona así».

Contenido

  1. Las cinco piezas
  2. Repaso: la pila de llamadas
  3. Las APIs del entorno: quién espera de verdad
  4. Las dos colas: macrotareas y microtareas
  5. El algoritmo del bucle de eventos
  6. La regla de oro de las microtareas
  7. Ejercicio clásico, resuelto con traza completa
  8. Dónde encaja await exactamente
  9. Bloquear el hilo: qué congela y qué no
  10. Por qué await no arregla un bucle pesado
  11. Trocear el trabajo y ceder el hilo
  12. requestAnimationFrame y temporizadores anidados
  13. Qué implica todo esto para el Módulo 6
  14. Errores Comunes y Consejos
  15. Ejercicios
  16. Conclusión

  1. Las cinco piezas

El sistema completo tiene cinco componentes. Solo uno de ellos es «JavaScript»; los demás los aporta el entorno —el navegador o Node—.

Pieza Qué es Quién la proporciona
Pila de llamadas Dónde se ejecuta el código; una sola El motor de JavaScript
APIs del entorno Temporizadores, red, eventos, ficheros El navegador / Node
Cola de macrotareas Callbacks listos de temporizadores y eventos El entorno
Cola de microtareas Callbacks de promesas y queueMicrotask El motor
Bucle de eventos El coordinador que mueve tareas a la pila El entorno
flowchart TD
    subgraph Motor["Motor de JavaScript"]
        P["Pila de llamadas<br/>(una sola, LIFO)"]
        MI["Cola de MICROtareas<br/>.then · await · queueMicrotask"]
    end
    subgraph Entorno["Entorno (navegador / Node)"]
        API["APIs: temporizadores,<br/>red, eventos, ficheros"]
        MA["Cola de MACROtareas<br/>setTimeout · setInterval · eventos"]
    end
    BE(["Bucle de eventos"])

    P -->|"registra la operación"| API
    API -->|"al terminar, encola el callback"| MA
    P -->|"promesa saldada"| MI
    BE -->|"1 · ¿pila vacía?"| P
    BE -->|"2 · vacía TODAS las microtareas"| MI
    BE -->|"3 · toma UNA macrotarea"| MA
    MI --> P
    MA --> P

La idea que hay que retener antes de entrar en detalle: la pila es el único sitio donde se ejecuta código, y el bucle de eventos es un portero que solo deja entrar algo nuevo cuando la pila está completamente vacía.

  1. Repaso: la pila de llamadas

En 03-05 aprendiste que cada llamada a función crea un contexto de ejecución que se apila, y que se desapila cuando la función retorna. Es una estructura LIFO: lo último que entra es lo primero que sale.

'use strict';

function esfuerzo(prioridad, horas) {
  return pesoDePrioridad(prioridad) * horas;
}

function pesoDePrioridad(prioridad) {
  return { alta: 3, media: 2, baja: 1 }[prioridad] ?? 0;
}

function informe() {
  const total = esfuerzo('alta', 12);
  console.log(total);       // 36
}

informe();

La pila evoluciona así:

[informe]                                     ← se llama a informe()
[informe, esfuerzo]                           ← informe llama a esfuerzo
[informe, esfuerzo, pesoDePrioridad]          ← esfuerzo llama a pesoDePrioridad
[informe, esfuerzo]                           ← pesoDePrioridad retorna 3
[informe]                                     ← esfuerzo retorna 36
[informe, console.log]                        ← se imprime
[]                                            ← pila vacía

Ese estado final —pila vacía— es la condición que el bucle de eventos vigila. Mientras haya algo en la pila, ningún callback asíncrono puede empezar. Y como la pila es única, «algo en la pila» significa que el hilo está ocupado.

  1. Las APIs del entorno: quién espera de verdad

Cuando escribes setTimeout(f, 1000), esa función no es de JavaScript. El lenguaje, tal como lo define su especificación, no sabe medir el tiempo ni hacer peticiones de red. setTimeout es una API del entorno: la aporta el navegador (o Node).

Lo que ocurre paso a paso:

  1. Tu código llama a setTimeout(f, 1000). Esta llamada entra en la pila.
  2. El entorno toma nota: guarda f y arranca un temporizador fuera del hilo de JavaScript.
  3. setTimeout retorna inmediatamente su identificador. Sale de la pila. Tu código sigue.
  4. Mil milisegundos después, el temporizador vence. El entorno mete f en la cola de macrotareas. Nada se ejecuta todavía.
  5. Cuando el bucle de eventos encuentra la pila vacía, saca f de la cola y la mete en la pila. Ahora sí se ejecuta.

Esto explica de una vez la afirmación del 05-05 de que «la asincronía no hace nada más rápido»: quien espera no es tu código, sino otro componente. El temporizador lo lleva el sistema operativo; la petición de red la lleva el subsistema HTTP del navegador, escrito en C++ y con sus propios hilos. Tu programa solo se apunta a que le avisen.

Las APIs del entorno más habituales:

API Qué espera En qué cola encola su callback
setTimeout / setInterval Tiempo Macrotareas
Eventos del DOM (clic, teclado) Interacción del usuario Macrotareas
fetch (07-02) Respuesta de red Microtareas (es una promesa)
Lectura de ficheros en Node Disco Macrotareas
requestAnimationFrame El siguiente repintado Cola propia (apartado 12)

  1. Las dos colas: macrotareas y microtareas

Aquí está la sutileza que explica casi todos los órdenes sorprendentes: no hay una cola, hay dos, y no tienen la misma prioridad.

Macrotareas (tasks) Microtareas (microtasks)
Qué encola aquí setTimeout, setInterval, eventos del DOM, E/S .then/.catch/.finally, reanudación tras await, queueMicrotask
Cuántas se procesan por vuelta Una Todas, hasta vaciar la cola
Prioridad Baja Alta
¿Puede la cola crecer mientras se procesa? Sí, pero espera a la vuelta siguiente Sí, y se procesa en la misma tanda
Se comprueba entre medias el repintado Sí, entre macrotareas No, hasta que la cola esté vacía

La regla que se deriva de la segunda fila es la más importante de la lección:

Entre dos macrotareas, el motor vacía la cola de microtareas por completo. Todas las promesas pendientes de resolver se atienden antes de que se ejecute el siguiente setTimeout.

Esto se demuestra en cuatro líneas:

'use strict';

setTimeout(() => console.log('macrotarea'), 0);
Promise.resolve().then(() => console.log('microtarea'));
console.log('síncrono');

// síncrono
// microtarea      ← aunque se registró DESPUÉS del setTimeout
// macrotarea

Aunque el setTimeout se escribió primero y su retardo es cero, la microtarea se atiende antes. No es un detalle de implementación de un navegador concreto: está en la especificación y se comporta igual en todos.

  1. El algoritmo del bucle de eventos

El bucle de eventos es, literalmente, un bucle infinito que repite estos pasos:

mientras (el programa siga vivo) {

  1. ¿Hay algo en la pila de llamadas?
        Sí → esperar. No se toca nada.
        No → continuar.

  2. Vaciar ENTERA la cola de microtareas:
        mientras (haya microtareas) {
            sacar la primera y ejecutarla hasta el final
            (si genera microtareas nuevas, se añaden a esta misma cola
             y también se procesan ahora)
        }

  3. [En el navegador] Si toca repintar, repintar ahora.

  4. Tomar UNA macrotarea de la cola y ejecutarla hasta el final.

  5. Volver al paso 2.
}

Cuatro consecuencias prácticas se leen directamente de ese algoritmo:

  • El código síncrono siempre gana. El paso 1 espera a que la pila esté vacía, y la pila no se vacía hasta que todo el script del nivel superior ha terminado.
  • Las microtareas van antes que las macrotareas, siempre, sin importar en qué orden se registraron (pasos 2 y 4).
  • Una macrotarea se ejecuta entera antes de que se atienda la siguiente. No hay interrupciones a mitad de función.
  • El repintado ocurre entre macrotareas, nunca a mitad. Es lo que hace que un bucle largo congele la pantalla.

  1. La regla de oro de las microtareas

El paso 2 tiene una consecuencia peligrosa: si una microtarea encola otra microtarea, esta se procesa en la misma tanda, no en la siguiente vuelta. Una cadena infinita de microtareas cuelga el programa para siempre, y el setTimeout nunca llega a ejecutarse.

'use strict';

// ⚠ NO ejecutes esto: congela la pestaña
function bucleDeMicrotareas() {
  Promise.resolve().then(bucleDeMicrotareas);    // cada una encola la siguiente
}
setTimeout(() => console.log('nunca llego'), 0);
bucleDeMicrotareas();

Compáralo con la versión equivalente hecha con macrotareas, que no cuelga nada:

'use strict';

let ciclos = 0;
function bucleDeMacrotareas() {
  ciclos += 1;
  if (ciclos < 1000) setTimeout(bucleDeMacrotareas, 0);
}
setTimeout(() => console.log('sí llego'), 0);
bucleDeMacrotareas();
// sí llego     ← porque cada vuelta cede el turno

La diferencia es exactamente el paso 4 del algoritmo: una macrotarea por vuelta, así que las demás tienen su oportunidad. Las microtareas no ceden.

Existe además una forma explícita de encolar una microtarea, sin promesa de por medio:

queueMicrotask(() => console.log('microtarea explícita'));

Se usa poco en código de aplicación, pero es útil para entender el modelo: es el equivalente exacto de Promise.resolve().then(...), sin crear una promesa.

Quieres… Usa
Ejecutar algo después del código actual, antes de repintar queueMicrotask o Promise.resolve().then
Ceder el hilo para que la interfaz respire setTimeout(f, 0)
Ejecutar justo antes del siguiente repintado requestAnimationFrame (apartado 12)

  1. Ejercicio clásico, resuelto con traza completa

Este fragmento es el examen estándar del bucle de eventos. Léelo, apuesta por un orden y luego sigue la traza.

'use strict';

console.log('1 · script');

setTimeout(() => console.log('2 · timeout A'), 0);

Promise.resolve()
  .then(() => console.log('3 · then A'))
  .then(() => console.log('4 · then B'));

async function tarea() {
  console.log('5 · dentro de tarea, antes del await');
  await null;
  console.log('6 · dentro de tarea, después del await');
}

tarea();

setTimeout(() => console.log('7 · timeout B'), 0);

queueMicrotask(() => console.log('8 · microtarea explícita'));

console.log('9 · fin del script');

Salida:

1 · script
5 · dentro de tarea, antes del await
9 · fin del script
3 · then A
6 · dentro de tarea, después del await
8 · microtarea explícita
4 · then B
2 · timeout A
7 · timeout B

Y ahora la traza, instante a instante.

Fase síncrona (la pila tiene el script; el bucle de eventos no interviene):

Línea Qué ocurre Colas al terminar
console.log('1') Imprime 1
setTimeout(…A…, 0) El entorno arranca el temporizador
Promise.resolve().then(…) La promesa ya está cumplida: encola then A micro: [then A]
.then(…B…) Se registra sobre una promesa pendiente (la que devuelve el primer .then). No se encola nada todavía micro: [then A]
tarea() Entra en la pila, imprime 5, llega al await micro: [then A]
await null null se envuelve en promesa cumplida → encola la reanudación de tarea. La función se suspende y retorna al script micro: [then A, reanudar tarea]
setTimeout(…B…, 0) Otro temporizador arrancado
queueMicrotask(…) Encola directamente micro: [then A, reanudar tarea, micro explícita]
console.log('9') Imprime 9
Fin del script La pila se vacía. Entra el bucle de eventos macro: [timeout A, timeout B]

Repara en la fila del segundo .then: no se encoló nada. Un .then solo encola su callback cuando la promesa sobre la que está registrado se salda, y la promesa que devuelve el primer .then sigue pendiente hasta que este se ejecute. Ese detalle es el que hace que 4 · then B acabe detrás de 8.

Paso 2 del algoritmo: vaciar las microtareas.

Se saca Imprime Efecto sobre la cola
then A 3 Su promesa se cumple → encola then B. Cola: [reanudar tarea, micro explícita, then B]
reanudar tarea 6 La función tarea continúa tras el await y termina
micro explícita 8
then B 4 Cola vacía → se sale del paso 2

Aquí se ve la regla de oro en acción: then B se añadió durante el vaciado y aun así se procesó en la misma tanda, antes de tocar ninguna macrotarea.

Paso 4: una macrotarea. Se ejecuta timeout A → imprime 2. Vuelta al paso 2: no hay microtareas. Siguiente macrotarea: timeout B → imprime 7.

flowchart TD
    S["FASE SÍNCRONA<br/>1 · 5 · 9"] --> M1["MICROTAREAS (todas)<br/>3 → 6 → 8 → 4"]
    M1 --> R["repintado (si toca)"]
    R --> T1["MACROTAREA 1<br/>2 · timeout A"]
    T1 --> M2["microtareas (ninguna)"]
    M2 --> T2["MACROTAREA 2<br/>7 · timeout B"]

Si has acertado el orden completo a la primera, has entendido el modelo. Si no, la parte que suele fallar es la de 4 · then B: recuerda que los .then encadenados no se encolan todos a la vez, sino uno tras otro conforme se van cumpliendo sus promesas.

  1. Dónde encaja await exactamente

Ya tienes la pieza que faltaba del 05-06. await hace tres cosas:

  1. Evalúa la expresión que tiene delante y, si no es una promesa, la envuelve en una cumplida.
  2. Suspende la función y retorna el control a quien la llamó. La pila se desmonta hasta ahí.
  3. Registra la reanudación como microtarea, que se ejecutará cuando la promesa se salde y le llegue el turno.

De ahí salen dos conclusiones importantes.

El cuerpo de una función async es síncrono hasta el primer await. Esto sorprende mucho:

'use strict';

async function cargar() {
  console.log('A · esto es síncrono');
  const tareas = await leerBacklog();
  console.log('C · esto es una microtarea');
  return tareas;
}

cargar();
console.log('B · esto va después de A');

// A · esto es síncrono
// B · esto va después de A
// C · esto es una microtarea   (cuando la promesa se salde)

Llamar a una función async no difiere su comienzo: se ejecuta inmediatamente hasta que encuentra un await. Es útil saberlo: si quieres que una función async valide sus argumentos y falle rápido, pon las validaciones antes del primer await y lanzarán en el mismo instante de la llamada… bueno, casi: como la función devuelve una promesa, el throw se convierte en un rechazo. Pero el trabajo se hace ya.

Cada await cuesta al menos una vuelta de microtareas. Aunque la promesa ya esté cumplida:

async function tres() {
  console.log('1');
  await null;          // microtarea
  console.log('2');
  await null;          // otra microtarea
  console.log('3');
}

tres();
Promise.resolve().then(() => console.log('X'));
Promise.resolve().then(() => console.log('Y'));

// 1
// X          ← se intercalan: cada await cede el turno
// 2
// Y
// 3

Ese intercalado es la prueba visual de que await no es una pausa mágica: es un return seguido de una reanudación encolada como microtarea. Y explica por qué poner cincuenta await innecesarios en una función tiene un coste real, aunque pequeño.

  1. Bloquear el hilo: qué congela y qué no

Ahora se puede explicar con precisión el problema que abría el 05-05.

'use strict';

console.log('Empieza el cálculo');

let suma = 0;
for (let i = 0; i < 2_000_000_000; i++) {
  suma += i;                       // varios segundos ocupando la pila
}

console.log('Termina el cálculo', suma);

Mientras ese bucle corre, la pila no se vacía. Y si la pila no se vacía:

  • el paso 1 del algoritmo nunca pasa;
  • ninguna microtarea se procesa;
  • ninguna macrotarea se procesa;
  • no hay repintado (paso 3), así que la pantalla se queda congelada exactamente como estaba;
  • los clics del usuario se encolan como macrotareas y se ejecutarán todos de golpe al terminar, con el desconcierto correspondiente.

Ese es el estado que el navegador acaba reportando como «la página no responde».

Conviene distinguir con claridad qué bloquea y qué no:

Operación ¿Bloquea el hilo? Por qué
Bucle de mil millones de iteraciones Ocupa la pila todo el tiempo
JSON.parse de un fichero de 50 MB Es código síncrono, por rápido que sea
Ordenar un array de un millón de elementos sort es síncrono
await leerBacklog() No Suspende la función y libera la pila
setTimeout(f, 5000) No El temporizador lo lleva el entorno
alert('hola') , y de forma brutal Es síncrono y bloqueante por diseño

La regla se resume en una frase: lo que bloquea no es esperar, es calcular. Una espera bien hecha libera el hilo; un cálculo largo lo secuestra, sea cual sea la sintaxis que lo rodee.

  1. Por qué await no arregla un bucle pesado

Un error muy extendido es pensar que envolver el cálculo en una función async lo hace no bloqueante.

'use strict';

// ✗ Sigue congelando exactamente igual
async function calcularEsfuerzoTotal(tareas) {
  let total = 0;
  for (let i = 0; i < 2_000_000_000; i++) {
    total += i;
  }
  return total;
}

console.log('antes');
calcularEsfuerzoTotal([]);        // la interfaz se congela igual
console.log('después');           // sale varios segundos más tarde

El motivo lo sabes desde el apartado 8: el cuerpo de una función async es síncrono hasta el primer await, y aquí no hay ninguno. async no crea un hilo ni traslada nada a otra parte; solo cambia lo que la función devuelve.

Y meter un await que no espera nada tampoco sirve:

async function tampocoArregla(tareas) {
  await null;                     // cede el hilo UNA vez, al principio
  for (let i = 0; i < 2_000_000_000; i++) { /* … */ }   // y luego lo secuestra igual
}

Después de esa microtarea, el bucle vuelve a ocupar la pila durante segundos. La única forma real de no bloquear con un cálculo pesado es una de estas dos:

  1. Trocearlo y ceder el hilo entre trozos (apartado 11).
  2. Sacarlo del hilo principal con un Web Worker, que sí ejecuta JavaScript en un hilo aparte con su propio bucle de eventos. Es la solución correcta para trabajo intensivo de verdad, y se estudia en 09-02.

  1. Trocear el trabajo y ceder el hilo

Trocear consiste en procesar un lote, devolver el control al bucle de eventos y programar el siguiente lote como macrotarea. Aplicado a un backlog gigantesco de Taller Nómada:

'use strict';

/**
 * Procesa un array por lotes sin bloquear el hilo.
 * @param {Array}    elementos
 * @param {Function} accion       qué hacer con cada elemento
 * @param {number}   tamanoLote   cuántos por vuelta
 * @returns {Promise<void>}
 */
function procesarPorLotes(elementos, accion, tamanoLote = 500) {
  return new Promise((resolve) => {
    let indice = 0;

    function siguienteLote() {
      const fin = Math.min(indice + tamanoLote, elementos.length);
      for (; indice < fin; indice++) {
        accion(elementos[indice], indice);
      }
      if (indice < elementos.length) {
        setTimeout(siguienteLote, 0);      // ← cede el hilo: entra una vuelta del bucle
      } else {
        resolve();
      }
    }

    siguienteLote();
  });
}

// Uso con un backlog simulado de 200 000 tareas
const enorme = Array.from({ length: 200_000 }, (_, i) => ({ id: i + 1, horasEstimadas: (i % 40) + 1 }));

let horas = 0;
await procesarPorLotes(enorme, (t) => { horas += t.horasEstimadas; }, 1000);
console.log(`Total: ${horas} h`);

Entre lote y lote, el bucle de eventos completa una vuelta: procesa microtareas, repinta y atiende los clics del usuario. La aplicación sigue viva. El precio es que el proceso completo tarda algo más —cada setTimeout anidado tiene su mínimo de unos 4 ms—, y ahí está el compromiso: el tamaño del lote decide el equilibrio entre fluidez y velocidad total.

Una alternativa moderna, cuando el objetivo es que la interfaz respire:

// Solo en navegadores que lo soporten
await scheduler.yield();      // "cede el turno y reanuda en cuanto puedas"

Y una advertencia importante: no uses await de una promesa ya cumplida para ceder el hilo. Como es una microtarea, se procesa en la misma vuelta, sin repintado ni atención a eventos. Para ceder de verdad hace falta una macrotarea (setTimeout) o una API específica.

Técnica ¿Cede el hilo de verdad?
await null / await Promise.resolve() No: microtarea
await new Promise((r) => setTimeout(r, 0)) : macrotarea
setTimeout(siguiente, 0)
Web Worker Sí, y además usa otro núcleo (09-02)

  1. requestAnimationFrame y temporizadores anidados

Dos apuntes breves para completar el mapa, que retomarás en el Módulo 6 y en el 9.

requestAnimationFrame(callback) programa una función para ejecutarse justo antes del siguiente repintado, sincronizada con la frecuencia de la pantalla (unas 60 veces por segundo en un monitor normal). No es ni macrotarea ni microtarea: tiene su propio momento en el algoritmo, el paso 3.

function animar(marcaDeTiempo) {
  // …actualizar posiciones…
  requestAnimationFrame(animar);       // se reprograma para el frame siguiente
}
requestAnimationFrame(animar);

La diferencia con setTimeout(f, 16) es que rAF está sincronizado con el repintado: no se ejecuta si la pestaña está oculta —lo que ahorra batería— y nunca produce dos actualizaciones entre dos fotogramas. Para cualquier animación, rAF es la opción correcta; setTimeout produce saltos.

Temporizadores anidados. Ya lo apuntamos en 05-05: por especificación, a partir del quinto setTimeout anidado los navegadores elevan el mínimo a unos 4 ms. Así que un setTimeout(f, 0) que se reprograma a sí mismo no da mil vueltas por segundo, sino unas doscientas cincuenta. Es una limitación deliberada para evitar que un bucle de temporizadores queme el procesador, y es la razón por la que trocear con setTimeout tiene un coste que hay que medir.

  1. Qué implica todo esto para el Módulo 6

En la lección siguiente cierras el Módulo 5, y en el 6 empiezas a construir la interfaz de Nómada Tareas. Todo lo que acabas de aprender se vuelve muy concreto allí. Anota estas cinco consecuencias:

  1. Los manejadores de eventos son macrotareas. Cada clic en un botón «Marcar como hecha» encola una macrotarea. Si tu manejador tarda 300 ms, el usuario percibe la interfaz como pegajosa, porque no se repinta nada mientras dura.

  2. Repintar solo ocurre entre macrotareas. Si en un mismo manejador cambias diez veces el texto de un elemento, el usuario verá solo el último valor: no hay repintado a mitad de función. Es una buena noticia —evita parpadeos— y explica por qué los cambios se agrupan de forma natural.

  3. Un cálculo pesado en un manejador congela la aplicación entera, con la lista de tareas a medio pintar y los botones sin responder. La lógica costosa se trocea, se saca a un worker o se hace fuera del manejador.

  4. El orden entre await y eventos importa. Si un manejador de clic hace await guardarTarea(), el usuario puede pulsar el botón otra vez durante la espera y encolar un segundo manejador. Desactivar el botón mientras la operación está en marcha no es un adorno: es corrección.

  5. <script type="module"> tiene defer implícito (05-04), así que tu código se ejecuta con el HTML ya construido, y esa ejecución es una macrotarea más dentro del ciclo de vida de la página.

Con eso, el comportamiento de la interfaz deja de ser misterioso: es el algoritmo del apartado 5, aplicado a eventos de usuario.

Errores Comunes y Consejos

  • Creer que setTimeout(f, 0) ejecuta ya. Solo encola. El retardo es un mínimo, y además hay que esperar a que la pila se vacíe.
  • Creer que async hace que algo no bloquee. async solo cambia lo que devuelve la función. Lo que bloquea es calcular, no la sintaxis.
  • Usar await Promise.resolve() para «dejar respirar» a la interfaz. Es una microtarea: se procesa en la misma vuelta, sin repintado. Hace falta una macrotarea.
  • Cadenas infinitas de microtareas. Una microtarea que encola otra sin condición de parada cuelga el programa sin remedio y sin mensaje de error.
  • Confiar en el tiempo exacto de un setInterval. Si el hilo está ocupado cuando vence, el callback se retrasa; y si se retrasa mucho, los navegadores fusionan repeticiones. Para medir tiempo real, usa marcas con Date.now() o performance.now().
  • Suponer un orden fijo entre callbacks de tipos distintos. Entre dos setTimeout con el mismo retardo, el orden de registro manda; entre una macrotarea y una microtarea, gana siempre la micro. Pero no supongas nada más allá de eso.
  • Depurar el bucle de eventos con console.log esperando ver la pila. El panel Performance de las DevTools dibuja la pila, las tareas y los repintados en una línea temporal: es la herramienta adecuada, y se estudia en 08-01 y 09-01.
  • Consejo: cuando un orden asíncrono te sorprenda, escríbelo como la traza del apartado 7: una tabla con la cola de microtareas y la de macrotareas después de cada línea. En cinco minutos se resuelve cualquier duda.

Ejercicios

Ejercicio 1 — Predecir y trazar. Indica la salida exacta de este fragmento y construye la tabla de la traza (estado de las dos colas tras cada línea síncrona).

console.log('inicio');

setTimeout(() => {
  console.log('T1');
  Promise.resolve().then(() => console.log('T1-micro'));
}, 0);

setTimeout(() => console.log('T2'), 0);

Promise.resolve().then(() => {
  console.log('P1');
  setTimeout(() => console.log('P1-timeout'), 0);
});

(async () => {
  console.log('async antes');
  await Promise.resolve();
  console.log('async después');
})();

console.log('fin');

Ejercicio 2 — Medir el bloqueo. Escribe dos versiones de una función que sume el esfuerzo ponderado de un array de 300 000 tareas simuladas: una síncrona y otra troceada con procesarPorLotes. Mide con Date.now() cuánto tarda cada una y, sobre todo, comprueba con un setInterval que imprima un punto cada 50 ms cuál de las dos permite que ese intervalo siga funcionando durante el cálculo.

Ejercicio 3 — Diagnóstico. Este código pretende mostrar un aviso mientras carga y ocultarlo al terminar, pero el aviso «no se ve nunca». Explica por qué usando el algoritmo del bucle de eventos y propón la corrección.

function cargarConAviso() {
  mostrarAviso('Cargando…');          // cambia el estado que se pintará
  const resultado = calculoPesadoSincrono();   // 3 segundos
  ocultarAviso();
  return resultado;
}

Soluciones

Ejercicio 1

Salida:

inicio
async antes
fin
P1
async después
T1
T1-micro
T2
P1-timeout

Traza de la fase síncrona:

Línea ejecutada Imprime Cola de microtareas Cola de macrotareas
console.log('inicio') inicio
setTimeout(T1, 0) [T1]
setTimeout(T2, 0) [T1, T2]
Promise.resolve().then(P1) [P1] [T1, T2]
IIFE async hasta el await async antes [P1, reanudar-async] [T1, T2]
console.log('fin') fin [P1, reanudar-async] [T1, T2]

Pila vacía → paso 2, vaciar microtareas: P1 imprime P1 y encola P1-timeout en macrotareas (queda [T1, T2, P1-timeout]); reanudar-async imprime async después. Cola de microtareas vacía.

Paso 4, una macrotarea: T1 imprime T1 y encola T1-micro en microtareas. Vuelta al paso 2: se vacía la cola → T1-micro. Siguiente macrotarea: T2. Después: P1-timeout.

El punto fino del ejercicio es que T1-micro sale inmediatamente después de T1, antes que T2, aunque T2 llevaba esperando en su cola desde el principio: al terminar cada macrotarea se vacían todas las microtareas pendientes.

Ejercicio 2

'use strict';

const PESOS = { alta: 3, media: 2, baja: 1 };
const PRIORIDADES = ['alta', 'media', 'baja'];

const enorme = Array.from({ length: 300_000 }, (_, i) => ({
  id: i + 1,
  prioridad: PRIORIDADES[i % 3],
  horasEstimadas: (i % 40) + 1
}));

// ── Versión síncrona ──────────────────────────────────────────────
function esfuerzoSincrono(tareas) {
  let total = 0;
  for (const t of tareas) total += PESOS[t.prioridad] * t.horasEstimadas;
  return total;
}

// ── Versión troceada ──────────────────────────────────────────────
function esfuerzoTroceado(tareas, tamanoLote = 5000) {
  return new Promise((resolve) => {
    let indice = 0;
    let total = 0;

    function lote() {
      const fin = Math.min(indice + tamanoLote, tareas.length);
      for (; indice < fin; indice++) {
        total += PESOS[tareas[indice].prioridad] * tareas[indice].horasEstimadas;
      }
      if (indice < tareas.length) setTimeout(lote, 0);
      else resolve(total);
    }
    lote();
  });
}

// ── El "latido" que revela si el hilo está libre ──────────────────
let latidos = 0;
const pulso = setInterval(() => { latidos += 1; }, 50);

let t0 = Date.now();
const a = esfuerzoSincrono(enorme);
const tiempoSincrono = Date.now() - t0;
const latidosDuranteSincrono = latidos;

latidos = 0;
t0 = Date.now();
const b = await esfuerzoTroceado(enorme);
const tiempoTroceado = Date.now() - t0;
const latidosDuranteTroceado = latidos;

clearInterval(pulso);

console.log(`Síncrono: ${a} en ${tiempoSincrono} ms · latidos: ${latidosDuranteSincrono}`);
console.log(`Troceado: ${b} en ${tiempoTroceado} ms · latidos: ${latidosDuranteTroceado}`);
// Síncrono: 82000000 en 12 ms · latidos: 0
// Troceado: 82000000 en 320 ms · latidos: 6

Los números concretos varían según la máquina, pero el patrón es siempre el mismo y es lo que hay que leer:

  • La versión síncrona es más rápida en total —no paga el coste de los setTimeout— pero registra cero latidos: durante todo el cálculo, el intervalo no se ejecutó ni una vez. El hilo estaba secuestrado, y en una aplicación real eso significa pantalla congelada.
  • La versión troceada tarda bastante más pero permite varios latidos: entre lote y lote, el bucle de eventos completó vueltas enteras, atendiendo temporizadores, repintando y respondiendo a clics.

Esa es la decisión de ingeniería en estado puro: se sacrifica tiempo total a cambio de que la aplicación siga viva. Si el cálculo cabe holgadamente en unos pocos milisegundos, no trocees; si va a durar más de unos 50 ms, trocea o llévalo a un Web Worker.

Ejercicio 3

El aviso no se ve porque el repintado ocurre en el paso 3 del algoritmo, entre macrotareas, y aquí la pila nunca se vacía. La secuencia real es:

  1. mostrarAviso('Cargando…') cambia el estado interno de la página… pero no dibuja nada: solo marca que hay que repintar.
  2. calculoPesadoSincrono() ocupa la pila tres segundos. El bucle de eventos no llega nunca al paso 3, así que no hay repintado.
  3. ocultarAviso() deshace el cambio, todavía sin que la pila se haya vaciado.
  4. La función retorna, la pila se vacía y por fin se repinta… mostrando el estado final, en el que el aviso ya está oculto.

El usuario ve tres segundos de congelación y ningún aviso. La corrección consiste en dejar que ocurra un repintado entre mostrar el aviso y empezar el cálculo, cediendo el hilo con una macrotarea:

const cederElHilo = () => new Promise((resolve) => setTimeout(resolve, 0));

async function cargarConAviso() {
  mostrarAviso('Cargando…');
  await cederElHilo();                     // ✓ el bucle completa una vuelta y REPINTA
  try {
    return await esfuerzoTroceado(datos);  // ✓ y además no congela mientras calcula
  } finally {
    ocultarAviso();                        // se ejecuta pase lo que pase (02-05)
  }
}

Dos detalles que hay que subrayar. El await cederElHilo() tiene que ser una macrotarea: si escribieras await Promise.resolve() sería una microtarea, se procesaría en la misma vuelta y no habría repintado, con lo que el problema seguiría exactamente igual. Y trocear el cálculo con esfuerzoTroceado resuelve la segunda mitad del problema: sin eso, el aviso se vería, pero la interfaz seguiría congelada durante los tres segundos.

Conclusión

Ya no queda nada de magia en la asincronía de JavaScript. El sistema tiene cinco piezas: una pila de llamadas única donde se ejecuta todo el código; las APIs del entorno que son quienes esperan de verdad —el temporizador lo lleva el sistema operativo, la red la lleva el navegador—; una cola de macrotareas para los callbacks de temporizadores y eventos; una cola de microtareas para las promesas, los await y queueMicrotask; y el bucle de eventos, un portero que solo deja entrar algo nuevo cuando la pila está completamente vacía.

Su algoritmo cabe en cuatro pasos, y de ellos se deduce todo lo demás: esperar a que la pila se vacíe, vaciar entera la cola de microtareas, repintar si toca y tomar una sola macrotarea antes de volver a empezar. De ahí salen las tres reglas operativas que debes tener grabadas: el código síncrono siempre gana; las microtareas se atienden todas antes que la siguiente macrotarea, sin importar en qué orden se registraron; y una cadena infinita de microtareas cuelga el programa mientras que una de macrotareas no, porque estas ceden el turno en cada vuelta. Has trazado el ejercicio clásico línea a línea y has visto por qué then B acaba detrás de la microtarea explícita —los .then encadenados no se encolan de golpe, sino conforme se cumplen sus promesas—, y sabes exactamente qué hace await: evalúa, suspende la función devolviendo el control, y encola la reanudación como microtarea, de modo que el cuerpo de una función async es síncrono hasta el primer await y cada await cuesta al menos una vuelta.

Y tienes el diagnóstico correcto del bloqueo: lo que congela no es esperar, es calcular. Un await libera la pila; un bucle de dos mil millones de iteraciones la secuestra, y con ella se van las microtareas, las macrotareas, los clics del usuario y —lo más visible— el repintado. Por eso envolver el cálculo en una función async no arregla nada, y por eso await Promise.resolve() tampoco cede el hilo de verdad: es una microtarea. Las salidas reales son dos: trocear el trabajo cediendo con una macrotarea entre lotes, aceptando conscientemente tardar más a cambio de que la aplicación siga viva, o sacarlo del hilo principal con un Web Worker (09-02). Sabes además que requestAnimationFrame tiene su propio momento, justo antes del repintado, y que los temporizadores anidados tienen un mínimo de unos 4 ms.

Con esto cierras la parte asíncrona del módulo, y llevas anotadas las cinco consecuencias que te esperan en el Módulo 6: los manejadores de eventos son macrotareas, el repintado solo ocurre entre ellas, un cálculo pesado dentro de un manejador congela la interfaz entera, y un botón que dispara una operación asíncrona hay que desactivarlo mientras dura. Queda una última pieza del lenguaje por descubrir, y es la que explica cómo funciona por dentro algo que llevas usando desde el Módulo 2 sin preguntártelo: qué hace exactamente for...of cuando recorre un array, un Map o un Set, cómo se puede hacer que un Tablero sea recorrible con esa misma sintaxis, y cómo se generan secuencias que se calculan solo cuando se piden —incluidas las infinitas y las asíncronas—. Es el tema de Iteradores y Generadores, la última lección del módulo.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados