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
- Las cinco piezas
- Repaso: la pila de llamadas
- Las APIs del entorno: quién espera de verdad
- Las dos colas: macrotareas y microtareas
- El algoritmo del bucle de eventos
- La regla de oro de las microtareas
- Ejercicio clásico, resuelto con traza completa
- Dónde encaja
awaitexactamente - Bloquear el hilo: qué congela y qué no
- Por qué
awaitno arregla un bucle pesado - Trocear el trabajo y ceder el hilo
requestAnimationFramey temporizadores anidados- Qué implica todo esto para el Módulo 6
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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.
- 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.
- 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:
- Tu código llama a
setTimeout(f, 1000). Esta llamada entra en la pila. - El entorno toma nota: guarda
fy arranca un temporizador fuera del hilo de JavaScript. setTimeoutretorna inmediatamente su identificador. Sale de la pila. Tu código sigue.- Mil milisegundos después, el temporizador vence. El entorno mete
fen la cola de macrotareas. Nada se ejecuta todavía. - Cuando el bucle de eventos encuentra la pila vacía, saca
fde 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) |
- 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
// macrotareaAunque 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.
- 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.
- 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 turnoLa 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:
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) |
- 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.
- Dónde encaja
await exactamente
await exactamenteYa tienes la pieza que faltaba del 05-06. await hace tres cosas:
- Evalúa la expresión que tiene delante y, si no es una promesa, la envuelve en una cumplida.
- Suspende la función y retorna el control a quien la llamó. La pila se desmonta hasta ahí.
- 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
// 3Ese 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.
- 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 | Sí | Ocupa la pila todo el tiempo |
JSON.parse de un fichero de 50 MB |
Sí | Es código síncrono, por rápido que sea |
| Ordenar un array de un millón de elementos | Sí | 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') |
Sí, 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.
- Por qué
await no arregla un bucle pesado
await no arregla un bucle pesadoUn 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 tardeEl 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:
- Trocearlo y ceder el hilo entre trozos (apartado 11).
- 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.
- 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)) |
Sí: macrotarea |
setTimeout(siguiente, 0) |
Sí |
| Web Worker | Sí, y además usa otro núcleo (09-02) |
requestAnimationFrame y temporizadores anidados
requestAnimationFrame y temporizadores anidadosDos 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.
- 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:
-
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.
-
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.
-
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.
-
El orden entre
awaity eventos importa. Si un manejador de clic haceawait 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. -
<script type="module">tienedeferimplí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
asynchace que algo no bloquee.asyncsolo 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 conDate.now()operformance.now(). - Suponer un orden fijo entre callbacks de tipos distintos. Entre dos
setTimeoutcon 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.logesperando 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:
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: 6Los 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:
mostrarAviso('Cargando…')cambia el estado interno de la página… pero no dibuja nada: solo marca que hay que repintar.calculoPesadoSincrono()ocupa la pila tres segundos. El bucle de eventos no llega nunca al paso 3, así que no hay repintado.ocultarAviso()deshace el cambio, todavía sin que la pila se haya vaciado.- 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
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
