Módulo 12: Asincronía

Objetivos del módulo

  • Entender la diferencia entre código síncrono y asíncrono, y por qué JavaScript necesita la asincronía
  • Comprender cómo funciona el event loop: pila de llamadas, APIs del entorno, macrotareas y microtareas
  • Usar temporizadores (setTimeout, setInterval) y cancelarlos
  • Reconocer los callbacks y el callback hell
  • Dominar las promesas: estados, then/catch/finally, encadenamiento y promisificación
  • Combinar promesas con Promise.all, allSettled, any y race, y conocer Promise.withResolvers y Promise.try
  • Escribir código asíncrono legible con async/await y gestionar sus errores
  • Distinguir ejecución secuencial y en paralelo, y evitar los errores típicos de await en bucles
  • Usar top-level await, generadores asíncronos, for await...of y Array.fromAsync (ES2026)
  • Cancelar operaciones con AbortController y AbortSignal
  • Detectar promesas rechazadas que nadie gestiona

1. Síncrono frente a asíncrono

📘 Concepto: Un código es síncrono cuando cada instrucción espera a que termine la anterior. Es asíncrono cuando lanzamos una operación lenta (descargar un fichero, esperar un temporizador, leer un disco) y seguimos haciendo otras cosas mientras tanto; cuando la operación termina, se nos avisa para continuar.

La analogía del restaurante

Imagina un restaurante con un único camarero:

  • Camarero síncrono: toma la comanda de la mesa 1, va a la cocina y se queda de pie esperando los 20 minutos que tarda el plato. Mientras tanto, las mesas 2, 3 y 4 no son atendidas. El restaurante parece “congelado”.
  • Camarero asíncrono: toma la comanda de la mesa 1, la deja en la cocina y vuelve a la sala a atender a la mesa 2, a la 3… Cuando el cocinero toca la campana (“¡plato listo!”), el camarero lo recoge y lo sirve.

JavaScript es ese camarero: tiene un solo hilo de ejecución (un único camarero), pero gracias a la asincronía puede atender muchas cosas sin quedarse bloqueado. La cocina son las APIs del entorno (el navegador o Node.js), que sí pueden trabajar en paralelo; la campana es el aviso que deja una tarea pendiente en una cola.

Por qué importa en la web

En el navegador, el mismo hilo que ejecuta tu JavaScript es el que pinta la página y responde a los clics. Si lo bloqueas, la página se congela:

// ❌ Bloqueo síncrono: durante 3 segundos la pestaña no responde (espera activa)
const fin = Date.now() + 3000;
while (Date.now() < fin) {}
console.log('Por fin libre');

// ✅ Asíncrono: programamos el aviso y el hilo queda libre
console.log('Pido el plato');
setTimeout(() => console.log('Plato servido (3 s después)'), 3000);
console.log('Mientras tanto, atiendo otras mesas');
// Salida: Pido el plato → Mientras tanto, atiendo otras mesas → Plato servido (3 s después)

⚠️ Importante: Asíncrono no significa “en paralelo dentro de tu código”. Tu JavaScript sigue ejecutándose en un solo hilo, de una instrucción en una. Lo que ocurre en paralelo es el trabajo del entorno (la red, el temporizador, el disco). Para paralelismo real de CPU existen los Web Workers, que verás en el módulo 18.


2. Un solo hilo y el event loop

Para entender por qué el código asíncrono se ejecuta en el orden en que lo hace, hay que conocer las piezas del motor.

Pieza Qué es Analogía
Pila de llamadas (call stack) Las funciones que se están ejecutando ahora mismo, una encima de otra Lo que el camarero tiene entre manos
APIs del entorno (Web APIs en el navegador, libuv en Node) Temporizadores, red, disco, eventos del DOM… trabajan fuera de tu hilo La cocina
Cola de macrotareas (task queue) Callbacks listos para ejecutarse: temporizadores, eventos, mensajes Platos listos en el pase
Cola de microtareas (microtask queue) Callbacks de promesas (then, await) y queueMicrotask Notas urgentes que el camarero lee antes de coger otro plato
Event loop El bucle que decide qué se ejecuta a continuación El jefe de sala

Las reglas del event loop

  1. Ejecuta el código síncrono hasta que la pila quede vacía.
  2. Vacía por completo la cola de microtareas (si una microtarea añade otra, también se ejecuta en esta ronda).
  3. El navegador puede repintar la pantalla si hace falta.
  4. Coge una sola macrotarea de su cola y la ejecuta (vuelve al paso 1).
flowchart LR
    subgraph Motor["Motor de JavaScript (un hilo)"]
        Pila["Pila de llamadas"]
    end
    subgraph Entorno["APIs del entorno"]
        T["Temporizadores"]
        R["Red (fetch)"]
        E["Eventos del DOM"]
    end
    Pila -- "setTimeout, fetch, addEventListener" --> Entorno
    T -- "callback listo" --> Macro["Cola de macrotareas"]
    E -- "callback listo" --> Macro
    R -- "respuesta: resuelve la promesa" --> Micro["Cola de microtareas"]
    Pila -- "then / await / queueMicrotask" --> Micro
    Loop(("Event loop"))
    Micro -- "1º: todas" --> Loop
    Macro -- "2º: una" --> Loop
    Loop -- "cuando la pila está vacía" --> Pila

El orden de los console.log

💻 Ejemplo: Este programa es el clásico de las entrevistas. Antes de leer la salida, intenta adivinar el orden. Guárdalo como orden.mjs y ejecútalo con node orden.mjs (o pégalo en la consola del navegador).

console.log('1. Inicio (síncrono)');

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

Promise.resolve().then(() => console.log('4. then de una promesa (microtarea)'));

queueMicrotask(() => console.log('5. queueMicrotask (microtarea)'));

(async () => {
  console.log('2. Dentro de una función async (síncrono hasta el primer await)');
  await null;
  console.log('5b. Después del await (microtarea)');
})();

console.log('3. Fin (síncrono)');

Salida real (Node.js 24):

1. Inicio (síncrono)
2. Dentro de una función async (síncrono hasta el primer await)
3. Fin (síncrono)
4. then de una promesa (microtarea)
5. queueMicrotask (microtarea)
5b. Después del await (microtarea)
6. setTimeout 0 (macrotarea)

¿Por qué?

  • Las líneas 1, 2 y 3 son síncronas: se ejecutan en cuanto se leen. Fíjate en que el cuerpo de una función async se ejecuta de forma síncrona hasta el primer await.
  • El then, el queueMicrotask y la continuación tras el await son microtareas: se encolan en orden y se ejecutan en cuanto la pila queda vacía.
  • El setTimeout(..., 0) es una macrotarea: aunque el retraso sea 0, tiene que esperar a que se vacíen todas las microtareas.

⚠️ Importante: setTimeout(fn, 0) no significa “ahora mismo”, sino “lo antes posible, cuando el hilo quede libre y no haya microtareas pendientes”. El retraso que indicas es un mínimo, nunca una garantía.

💡 Consejo: Como las microtareas se vacían por completo antes de pasar a la siguiente macrotarea, un bucle infinito de microtareas (una que encola otra sin fin) congela la página igual que un while (true).


3. Temporizadores

setTimeout y clearTimeout

setTimeout(funcion, ms, ...argumentos) ejecuta la función una vez pasados al menos ms milisegundos. Devuelve un identificador que sirve para cancelarlo con clearTimeout.

const saludar = (nombre) => console.log(`Hola, ${nombre}`);

const id = setTimeout(saludar, 1000, 'Ana'); // los argumentos extra se pasan a la función
clearTimeout(id); // si cambiamos de idea antes de que pase el segundo, no se muestra nada

setInterval y clearInterval

setInterval repite la función cada ms milisegundos hasta que lo detienes:

let segundos = 3;
const intervalo = setInterval(() => {
  console.log(segundos--);
  if (segundos === 0) {
    clearInterval(intervalo);
    console.log('¡Despegue!');
  }
}, 1000);
// Salida (una línea por segundo): 3, 2, 1, ¡Despegue!

setInterval lanza la función cada X ms aunque la anterior no haya terminado (por ejemplo, si cada ejecución hace una petición lenta). Un setTimeout que se reprograma al terminar garantiza una pausa entre ejecuciones:

async function sondear() {
  try {
    await comprobarNovedades(); // una función asíncrona cualquiera
  } finally {
    setTimeout(sondear, 5000); // la siguiente consulta se programa al acabar esta
  }
}

💡 Consejo: Los navegadores ralentizan los temporizadores de las pestañas en segundo plano (pueden pasar a ejecutarse como mucho una vez por segundo, o menos). No uses setInterval para medir el tiempo: guarda la hora de inicio con Date.now() o performance.now() y calcula la diferencia.

Función Ejecuta Cancelar con
setTimeout(fn, ms) Una vez, tras ms como mínimo clearTimeout(id)
setInterval(fn, ms) Repetidamente cada ms clearInterval(id)
queueMicrotask(fn) Una vez, como microtarea No se puede cancelar

4. Callbacks y el callback hell

📘 Concepto: Un callback es una función que pasas a otra para que te llame cuando termine. Es la forma más antigua de gestionar la asincronía en JavaScript: “toma esta función y ejecútala cuando tengas el resultado”.

Ya has usado callbacks síncronos (array.map(x => x * 2)). Los asíncronos son iguales, pero se ejecutan más tarde:

// Simulamos una petición lenta con setTimeout.
// Convención "error primero" (error-first) de Node.js:
// el primer parámetro del callback es el error (o null) y el segundo, el resultado
function obtenerUsuarioCallback(id, callback) {
  setTimeout(() => {
    if (id <= 0) callback(new Error('Id no válido'));
    else callback(null, { id, nombre: 'Ana' });
  }, 500);
}

obtenerUsuarioCallback(1, (error, usuario) => {
  if (error) return console.error(error.message);
  console.log(usuario.nombre); // Ana
});

El problema: el callback hell

Cuando una operación depende del resultado de la anterior, los callbacks se anidan unos dentro de otros y forman la famosa pirámide de la perdición:

obtenerUsuarioCallback(1, (error, usuario) => {
  if (error) return mostrarError(error);
  obtenerPedidos(usuario.id, (error, pedidos) => {
    if (error) return mostrarError(error);
    obtenerDetalle(pedidos[0].id, (error, detalle) => {
      if (error) return mostrarError(error);
      calcularEnvio(detalle, (error, envio) => {
        if (error) return mostrarError(error);
        console.log('Coste de envío:', envio.coste);
        // ...y seguimos hacia la derecha
      });
    });
  });
});

Problemas de este estilo:

  • Legibilidad: el código crece hacia la derecha y cuesta seguir el flujo.
  • Errores: hay que comprobar el error en cada nivel; si olvidas uno, el fallo se pierde.
  • Composición: hacer dos cosas en paralelo y esperar a ambas, o poner un tiempo máximo, es muy laborioso.
  • Confianza: dependes de que la otra función llame a tu callback exactamente una vez (ni cero, ni dos).

Las promesas nacieron (ES2015) para resolver todo esto.


5. Promesas

📘 Concepto: Una promesa (Promise) es un objeto que representa un valor que todavía no tienes pero tendrás en el futuro (o un error si algo sale mal). Es como el ticket con número que te dan en una hamburguesería: no es la hamburguesa, pero te garantiza que cuando esté lista te avisarán, o te dirán que se ha acabado el pan.

Estados de una promesa

stateDiagram-v2
    [*] --> Pendiente: new Promise(...)
    Pendiente --> Cumplida: resolve(valor)
    Pendiente --> Rechazada: reject(error)
    Cumplida --> [*]
    Rechazada --> [*]
Estado Inglés Significado
Pendiente pending La operación aún no ha terminado
Cumplida fulfilled Terminó bien y tiene un valor
Rechazada rejected Falló y tiene un motivo (normalmente un Error)

Cuando una promesa está cumplida o rechazada se dice que está resuelta o asentada (settled) y ya no puede cambiar de estado.

Crear una promesa con new Promise

El constructor recibe una función ejecutora con dos parámetros: resolve (para cumplirla) y reject (para rechazarla).

function esperar(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function tirarDado() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const valor = Math.ceil(Math.random() * 6);
      if (valor === 1) reject(new Error('Ha salido un 1: pierdes'));
      else resolve(valor);
    }, 500);
  });
}

⚠️ Importante: Rechaza siempre con un objeto Error (o una subclase, como viste en el módulo 10), no con cadenas: así conservas la traza de la pila (stack) y puedes usar instanceof.

Consumir una promesa: then, catch y finally

tirarDado()
  .then((valor) => console.log(`Has sacado un ${valor}`))
  .catch((error) => console.error(error.message))
  .finally(() => console.log('Partida terminada')); // siempre, haya ido bien o mal
Método Se ejecuta cuando… Recibe
.then(alCumplir) La promesa se cumple El valor
.then(alCumplir, alRechazar) Cumple o rechaza (segundo argumento, poco usado) Valor o motivo
.catch(alRechazar) La promesa (o cualquier anterior de la cadena) se rechaza El motivo
.finally(fn) Siempre, al asentarse Nada (no recibe ni valor ni error)

Encadenamiento

La clave de las promesas: then devuelve una promesa nueva. Lo que devuelvas dentro de un then será el valor del siguiente; si devuelves una promesa, el siguiente then espera a que se resuelva. Así la pirámide se convierte en una lista plana:

obtenerUsuario(1)
  .then((usuario) => obtenerPedidos(usuario.id)) // devuelve una promesa
  .then((pedidos) => obtenerDetalle(pedidos[0].id))
  .then((detalle) => calcularEnvio(detalle))
  .then((envio) => console.log('Coste de envío:', envio.coste))
  .catch((error) => mostrarError(error)); // un único catch para toda la cadena

Si cualquier paso lanza un error, se saltan los then siguientes y se salta directamente al catch:

Promise.resolve(1)
  .then((x) => x + 1) // 2
  .then((x) => {
    throw new Error(`Fallo en el paso 2 (x = ${x})`);
  })
  .then(() => 'esto no se ejecuta')
  .catch((error) => console.log(error.message));

// Salida: Fallo en el paso 2 (x = 2)

⚠️ Importante: El error más común con promesas es olvidar el return dentro de un then con llaves. Si escribes .then((u) => { obtenerPedidos(u.id); }), el siguiente then recibe undefined y no espera a nada. Con la arrow function sin llaves (.then((u) => obtenerPedidos(u.id))) el return es implícito.

Promise.resolve y Promise.reject

Crean promesas ya asentadas. Son útiles para empezar una cadena, para funciones que a veces tienen el dato en caché, o en los tests:

const cache = new Map();

function obtenerProducto(id) {
  if (!Number.isInteger(id)) return Promise.reject(new TypeError('El id debe ser un entero'));
  if (cache.has(id)) return Promise.resolve(cache.get(id)); // promesa cumplida al momento
  return pedirAlServidor(id).then((producto) => {
    cache.set(id, producto);
    return producto;
  });
}

📘 Concepto: JavaScript trata como promesa a cualquier objeto con un método then (un thenable). Por eso await y Promise.resolve funcionan con librerías antiguas que tenían sus propias promesas.

Promisificar: convertir callbacks en promesas

Muchas APIs antiguas usan callbacks. Envolverlas en una promesa se llama promisificar. Así quedaría obtenerUsuarioCallback de la sección anterior:

function obtenerUsuario(id) {
  return new Promise((resolve, reject) => {
    obtenerUsuarioCallback(id, (error, usuario) => {
      if (error) reject(error);
      else resolve(usuario);
    });
  });
}

obtenerUsuario(1).then((u) => console.log(u.nombre)); // Ana

Node.js trae la utilidad promisify y versiones con promesas de sus módulos:

import { promisify } from 'node:util';
import { setTimeout as esperar } from 'node:timers/promises';
import { readFile } from 'node:fs/promises';

const obtenerUsuarioP = promisify(obtenerUsuarioCallback); // para funciones error-first

await esperar(1000); // setTimeout que devuelve una promesa
const texto = await readFile('notas.txt', 'utf8');

6. Combinar varias promesas

Con frecuencia lanzas varias operaciones a la vez y quieres esperar a todas, a la primera, o a la primera que salga bien. Para eso están los combinadores: reciben un iterable de promesas y devuelven una promesa nueva.

En los ejemplos usaremos estas funciones auxiliares (creamos las promesas al llamar a cada función, no antes, para no dejar rechazos sin gestionar):

const esperar = (ms, valor) => new Promise((resolve) => setTimeout(() => resolve(valor), ms));
const rapida = () => esperar(100, 'rápida');
const lenta = () => esperar(300, 'lenta');
const fallida = () => new Promise((_, reject) => setTimeout(() => reject(new Error('fallo')), 200));

Promise.all: todas o ninguna

Espera a que todas se cumplan y devuelve un array con los valores en el mismo orden en que las pasaste (no en el orden en que terminan). Si una falla, rechaza inmediatamente con ese error.

console.log(await Promise.all([rapida(), lenta()])); // [ 'rápida', 'lenta' ] (~300 ms, no 400)

await Promise.all([rapida(), fallida(), lenta()]).catch((e) => console.log(e.message)); // fallo (~200 ms)

Promise.allSettled: espera a todas, salgan como salgan

Nunca rechaza. Devuelve un array de objetos { status, value } o { status, reason }:

const resultados = await Promise.allSettled([rapida(), fallida()]);
console.log(resultados);
// [
//   { status: 'fulfilled', value: 'rápida' },
//   { status: 'rejected', reason: Error: fallo ... }
// ]

const correctos = resultados.filter((r) => r.status === 'fulfilled').map((r) => r.value);

Promise.any: la primera que salga bien

Se cumple con la primera promesa cumplida e ignora los fallos. Solo rechaza si todas fallan, con un AggregateError que contiene todos los errores en errors:

console.log(await Promise.any([fallida(), lenta()])); // lenta

try {
  await Promise.any([Promise.reject(new Error('a')), Promise.reject(new Error('b'))]);
} catch (error) {
  console.log(error.name, '-', error.message); // AggregateError - All promises were rejected
  console.log(error.errors.map((e) => e.message)); // [ 'a', 'b' ]
}

Promise.race: la primera en terminar

Se asienta con la primera que termine, bien o mal:

console.log(await Promise.race([rapida(), lenta()])); // rápida
await Promise.race([fallida(), lenta()]).catch((e) => console.log(e.message)); // fallo (termina antes)

Tabla comparativa

Combinador Se cumple cuando… Rechaza cuando… Resultado Uso típico
Promise.all Todas se cumplen Una falla (la primera) Array de valores Datos que necesitas todos (perfil + permisos)
Promise.allSettled Todas terminan Nunca Array de { status, value/reason } Lotes en los que algunos pueden fallar
Promise.any Una se cumple Todas fallan (AggregateError) El primer valor Varios servidores espejo: el primero que responda
Promise.race La primera termina bien La primera termina mal El primer valor o error Competir con un temporizador

⚠️ Importante: Los combinadores no cancelan las promesas que “pierden”. En Promise.race, la petición lenta sigue en marcha aunque ya no te interese su resultado. Para cancelarla de verdad necesitas AbortController (sección 11).

Promise.withResolvers() (ES2024)

A veces necesitas crear una promesa y resolverla desde fuera del constructor (por ejemplo, desde un manejador de eventos). Antes había que sacar resolve y reject a variables externas; ahora:

const { promise, resolve, reject } = Promise.withResolvers();
setTimeout(() => resolve('resuelta desde fuera'), 50);
console.log(await promise); // resuelta desde fuera

// Un caso real: una promesa que se cumple cuando el usuario pulsa un botón
function esperarClic(boton) {
  const { promise, resolve } = Promise.withResolvers();
  boton.addEventListener('click', resolve, { once: true });
  return promise;
}

await esperarClic(document.querySelector('#aceptar'));
console.log('El usuario ha aceptado');

Promise.try() (ES2025)

Promise.try(fn, ...args) ejecuta fn y siempre devuelve una promesa: si fn devuelve un valor, la promesa se cumple con él; si devuelve una promesa, la sigue; y si lanza un error síncrono, la promesa se rechaza (en vez de reventar fuera de la cadena).

function leerConfiguracion(texto) {
  return JSON.parse(texto); // puede lanzar SyntaxError de forma síncrona
}

Promise.try(leerConfiguracion, '{ mal json')
  .then((config) => console.log(config))
  .catch((error) => console.log('Capturado:', error.name)); // Capturado: SyntaxError

console.log(await Promise.try((a, b) => a + b, 2, 3)); // 5

💡 Consejo: Promise.try es útil cuando recibes una función que no sabes si es síncrona o asíncrona (por ejemplo, un plugin o un callback del usuario) y quieres tratar todos sus errores en el mismo catch.


7. async y await

Las promesas eliminaron la pirámide, pero las cadenas de then siguen siendo distintas del código “normal”. async/await (ES2017) permite escribir código asíncrono con el aspecto de código síncrono.

📘 Concepto:

  • Una función marcada con async devuelve siempre una promesa. Si haces return valor, la promesa se cumple con valor; si lanzas un error, se rechaza.
  • Dentro de ella, await promesa pausa esa función (no el programa entero) hasta que la promesa se asienta, y devuelve su valor o lanza su error.
async function sumar(a, b) {
  return a + b;
}
console.log(sumar(2, 3)); // Promise { 5 }
console.log(await sumar(2, 3)); // 5

Compara la cadena de then de la sección anterior con su versión async:

async function mostrarCosteEnvio(idUsuario) {
  const usuario = await obtenerUsuario(idUsuario);
  const pedidos = await obtenerPedidos(usuario.id);
  const detalle = await obtenerDetalle(pedidos[0].id);
  const envio = await calcularEnvio(detalle);
  console.log('Coste de envío:', envio.coste);
}

Se lee de arriba abajo, como una receta. async vale para cualquier tipo de función: declaraciones, expresiones, arrow functions (async () => {}) y métodos de clases u objetos (async cargar() {}).

Gestionar errores con try/catch

Un await sobre una promesa rechazada lanza el error, así que se captura con el try/catch de toda la vida (módulo 10):

async function jugar() {
  try {
    const valor = await tirarDado();
    console.log(`Has sacado un ${valor}`);
  } catch (error) {
    console.error('Error:', error.message);
  } finally {
    console.log('Partida terminada');
  }
}

También puedes combinar estilos: const prefs = await cargarPreferencias().catch(() => ({ tema: 'claro' })); da un valor por defecto si falla, sin try/catch. Y si haces await de algo que no es una promesa, obtienes ese valor tal cual (await 42 vale 42).

⚠️ Importante: return await promesa y return promesa se comportan distinto dentro de un try. Con return promesa, si la promesa falla, el error no pasa por tu catch (la función ya ha devuelto la promesa). Con return await promesa, sí. Si quieres capturar el error en esa función, usa return await.


8. Secuencial frente a paralelo

Este es el error de rendimiento más habitual con async/await: encadenar await de operaciones independientes.

const esperar = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// ❌ Secuencial: cada espera empieza cuando acaba la anterior
console.time('secuencial');
for (const ms of [200, 200, 200]) await esperar(ms);
console.timeEnd('secuencial'); // secuencial: ~600 ms

// ✅ Paralelo: lanzamos las tres y esperamos a todas juntas
console.time('paralelo');
await Promise.all([200, 200, 200].map((ms) => esperar(ms)));
console.timeEnd('paralelo'); // paralelo: ~200 ms

Regla práctica:

  • Si la operación B necesita el resultado de A → secuencial (await uno tras otro).
  • Si son independientes → lánzalas a la vez y espera con Promise.all (o allSettled).
// Perfil y notificaciones no dependen entre sí: en paralelo
const [perfil, notificaciones] = await Promise.all([obtenerPerfil(id), obtenerNotificaciones(id)]);

Errores comunes de await en bucles

1. forEach no espera. forEach ignora la promesa que devuelve tu callback async:

const resultado = [];
[3, 1, 2].forEach(async (n) => {
  await esperar(n * 10);
  resultado.push(n);
});
console.log(resultado); // [] ← ¡todavía no ha terminado nada!
// (unos milisegundos después resultado valdrá [1, 2, 3], en orden de llegada)

Soluciones: for...of con await si necesitas orden secuencial, o map + Promise.all si puede ir en paralelo.

for (const n of [3, 1, 2]) await procesar(n); // secuencial y en orden
const procesados = await Promise.all([3, 1, 2].map((n) => procesar(n))); // paralelo

2. map con async devuelve promesas, no valores.

const usuarios = [1, 2, 3].map(async (id) => obtenerUsuario(id));
console.log(usuarios); // [ Promise { <pending> }, Promise { <pending> }, Promise { <pending> } ]
const reales = await Promise.all(usuarios); // ✅ ahora sí

3. filter y reduce con async no funcionan como esperas. Una promesa siempre es “verdadera”, así que filter(async ...) no filtra nada. Resuelve primero los datos con Promise.all y filtra después.

Paralelo con límite de concurrencia

Lanzar 1000 peticiones a la vez puede saturar el servidor. Un patrón sencillo es procesar por lotes (lotes en secuencia, elementos de cada lote en paralelo):

async function enLotes(elementos, tamanoLote, tarea) {
  const resultados = [];
  for (let i = 0; i < elementos.length; i += tamanoLote) {
    resultados.push(...(await Promise.all(elementos.slice(i, i + tamanoLote).map(tarea))));
  }
  return resultados;
}
const imagenes = await enLotes(urls, 5, (url) => descargar(url)); // de 5 en 5

9. Top-level await

En los módulos ES (ficheros .mjs, proyectos con "type": "module" o <script type="module"> en el navegador) puedes usar await fuera de cualquier función, en el nivel superior del fichero (ES2022):

// configuracion.mjs
import { readFile } from 'node:fs/promises';

const texto = await readFile(new URL('./config.json', import.meta.url), 'utf8');
export const configuracion = JSON.parse(texto);

Los módulos que importen configuracion.mjs esperarán a que termine su await antes de ejecutarse, así que siempre recibirán la configuración ya cargada.

⚠️ Importante: El top-level await solo funciona en módulos. En un script clásico (<script> sin type="module" o un .cjs de Node) obtendrás un SyntaxError. Úsalo con moderación: un await lento en un módulo retrasa a todos los que lo importan. Verás los módulos a fondo en el módulo 13.


10. Iteración asíncrona

Generadores asíncronos y for await...of

En el módulo 11 viste los generadores (function*), que producen valores bajo demanda. Un generador asíncrono (async function*) puede hacer await entre valor y valor, y se recorre con for await...of. Es perfecto para datos que llegan por partes: páginas de una API, líneas de un fichero, mensajes de un stream.

// Simula una API que devuelve los resultados página a página
async function* paginas(total) {
  for (let pagina = 1; pagina <= total; pagina++) {
    await new Promise((r) => setTimeout(r, 20)); // simulamos la red
    yield [`item-${pagina}-a`, `item-${pagina}-b`];
  }
}

for await (const lote of paginas(3)) {
  console.log('Lote:', lote.join(', '));
}

// Salida: Lote: item-1-a, item-1-b / Lote: item-2-a, item-2-b / Lote: item-3-a, item-3-b

Con una API real quedaría así. Usamos JSONPlaceholder (jsonplaceholder.typicode.com), una API pública y gratuita de datos falsos pensada para practicar, que acepta los parámetros _page y _limit para paginar (verás fetch a fondo en el módulo 17):

async function* todosLosPosts() {
  for (let pagina = 1; ; pagina++) {
    const respuesta = await fetch(`https://jsonplaceholder.typicode.com/posts?_page=${pagina}&_limit=20`);
    const posts = await respuesta.json();
    if (posts.length === 0) return; // no hay más páginas
    yield* posts; // entrega los posts de uno en uno
  }
}

for await (const post of todosLosPosts()) {
  if (post.id > 45) break; // break cierra el generador: no se piden más páginas
  console.log(post.id, post.title);
}

for await...of también funciona con un array de promesas: espera a cada una en orden.

Array.fromAsync() (ES2026)

Array.fromAsync es el hermano asíncrono de Array.from: recorre un iterable asíncrono (o un iterable de promesas) y devuelve una promesa con un array de todos los valores.

async function* contar(hasta) {
  for (let i = 1; i <= hasta; i++) {
    await new Promise((r) => setTimeout(r, 10));
    yield i;
  }
}

console.log(await Array.fromAsync(contar(3))); // [ 1, 2, 3 ]
console.log(await Array.fromAsync(contar(3), (n) => n * 10)); // [ 10, 20, 30 ] (con mapeo)
console.log(await Array.fromAsync([Promise.resolve('a'), 'b'])); // [ 'a', 'b' ] (iterable de promesas)

// Juntar todas las páginas del generador anterior en un solo array
console.log((await Array.fromAsync(paginas(2))).flat()); // [ 'item-1-a', 'item-1-b', 'item-2-a', 'item-2-b' ]

⚠️ Importante: Array.fromAsync espera los elementos de uno en uno, en secuencia, igual que for await...of. Para un array de promesas ya lanzadas que quieres esperar en paralelo, sigue prefiriendo Promise.all: rechaza en cuanto cualquiera falla, mientras que Array.fromAsync no “ve” un fallo hasta que llega a esa posición. Y no lo uses con iterables infinitos: nunca terminaría.


11. Cancelar operaciones: AbortController y AbortSignal

Las promesas no se pueden cancelar por sí mismas. El mecanismo estándar (navegador y Node.js) es el AbortController:

📘 Concepto: Un AbortController es el mando a distancia y su signal (un AbortSignal) es el receptor que entregas a la operación. Cuando pulsas el botón (controlador.abort()), la señal se marca como abortada (signal.aborted === true), guarda el motivo en signal.reason y emite el evento abort.

const controlador = new AbortController();

fetch('https://jsonplaceholder.typicode.com/photos', { signal: controlador.signal })
  .then((respuesta) => respuesta.json())
  .then((fotos) => console.log(fotos.length))
  .catch((error) => {
    if (error.name === 'AbortError') console.log('Petición cancelada por el usuario');
    else throw error;
  });

controlador.abort(); // por ejemplo, al pulsar "Cancelar"

Tiempo máximo con AbortSignal.timeout(ms)

Crea una señal que se aborta sola pasado un tiempo, con un error de nombre TimeoutError:

try {
  const respuesta = await fetch('https://jsonplaceholder.typicode.com/users', { signal: AbortSignal.timeout(5000) });
  console.log(await respuesta.json());
} catch (error) {
  if (error.name === 'TimeoutError') console.log('El servidor ha tardado más de 5 s');
}

Combinar señales con AbortSignal.any([...])

Devuelve una señal que se aborta en cuanto cualquiera de las señales que le pasas se aborte. Ideal para “cancelar si el usuario lo pide o si pasan 10 segundos”:

const usuario = new AbortController();
const signal = AbortSignal.any([usuario.signal, AbortSignal.timeout(10_000)]);

botonCancelar.addEventListener('click', () => usuario.abort(new Error('El usuario canceló')));
await fetch(url, { signal });

Hacer cancelables tus propias funciones

Cualquier función asíncrona puede aceptar una signal. El patrón es: comprobar si ya está abortada, escuchar el evento abort y limpiar lo que tengas en marcha.

function esperar(ms, { signal } = {}) {
  return new Promise((resolve, reject) => {
    signal?.throwIfAborted(); // si ya estaba abortada, lanza signal.reason
    const id = setTimeout(resolve, ms);
    signal?.addEventListener(
      'abort',
      () => {
        clearTimeout(id); // limpiamos el temporizador
        reject(signal.reason); // rechazamos con el motivo de la cancelación
      },
      { once: true },
    );
  });
}

const controlador = new AbortController();
setTimeout(() => controlador.abort(), 50);
await esperar(500, { signal: controlador.signal }).catch((e) => console.log(e.name, '-', e.message));
// AbortError - This operation was aborted

await esperar(500, { signal: AbortSignal.timeout(60) }).catch((e) => console.log(e.name, '-', e.message));
// TimeoutError - The operation was aborted due to timeout
API Para qué sirve
new AbortController() Crea el mando; su propiedad signal es la señal
controlador.abort(motivo?) Aborta la señal (motivo por defecto: DOMException llamada AbortError)
signal.aborted true si ya se ha abortado
signal.reason El motivo de la cancelación
signal.throwIfAborted() Lanza signal.reason si está abortada
AbortSignal.timeout(ms) Señal que se aborta sola con un TimeoutError
AbortSignal.any(señales) Señal que se aborta cuando lo hace cualquiera de las dadas
AbortSignal.abort(motivo?) Señal ya abortada desde el principio (útil en tests)

💡 Consejo: La misma signal sirve para quitar listeners de eventos (addEventListener(tipo, fn, { signal })), como verás en el módulo 15. Un solo abort() puede cancelar peticiones, temporizadores y listeners a la vez.


12. Errores no capturados

Si una promesa se rechaza y nadie la gestiona (sin catch ni try/catch alrededor del await), el error no desaparece:

  • En el navegador aparece en la consola como Uncaught (in promise) y se emite el evento unhandledrejection en window.
  • En Node.js (desde la versión 15), un rechazo no gestionado termina el proceso con código de error, salvo que escuches el evento unhandledRejection de process.
// Navegador: red de seguridad global para registrar errores
window.addEventListener('unhandledrejection', (evento) => {
  console.error('Promesa rechazada sin gestionar:', evento.reason);
  enviarAlServicioDeLogs(evento.reason); // por ejemplo
  // evento.preventDefault(); evitaría el mensaje por defecto en consola
});

// Node.js
process.on('unhandledRejection', (motivo) => {
  console.error('Rechazo no gestionado:', motivo.message);
  process.exitCode = 1;
});

setTimeout(() => Promise.reject(new Error('nadie me escucha')), 100);
// Salida: Rechazo no gestionado: nadie me escucha

Una trampa sutil

Una promesa que se rechaza antes de que le pongas el catch cuenta como no gestionada:

const fallo = new Promise((_, reject) => setTimeout(() => reject(new Error('fallo')), 200));
const lenta = new Promise((resolve) => setTimeout(resolve, 300));

await lenta; // mientras esperamos, "fallo" se rechaza sin manejador...
await fallo.catch(() => {}); // ...y llegamos tarde: en Node el proceso ya ha terminado

La solución es esperar a ambas juntas (await Promise.all([lenta, fallo])) o crear cada promesa justo cuando vayas a esperarla.

⚠️ Importante: Los manejadores globales son una red de seguridad para registrar errores, no una forma de gestionarlos. Cada promesa debería tener su catch o su try/catch en el lugar adecuado. Algunas reglas de linting con información de tipos (como no-floating-promises del paquete typescript-eslint) detectan promesas “flotantes” sin gestionar; ESLint y TypeScript los verás en el módulo 21.


13. Ejemplo completo: descargas simuladas con reintentos

Vamos a juntar casi todo lo del módulo en un programa de Node.js que simula tres descargas en paralelo, cada una con su duración y con fallos de red aleatorios (aquí, fijos para que la salida sea reproducible). Las descargas fallidas se reintentan con backoff exponencial (cada espera es el doble que la anterior) y todo el proceso tiene un tiempo máximo global.

📘 Concepto: El backoff exponencial consiste en esperar cada vez más entre reintentos (100 ms, 200 ms, 400 ms…). Si un servidor está saturado, reintentar inmediatamente solo empeora las cosas; esperar más cada vez le da tiempo a recuperarse.

// descargas.mjs — simulador de descargas con reintentos y backoff exponencial

const inicio = performance.now();
const t = () => `[${String(Math.round((performance.now() - inicio) / 100) * 100).padStart(4)} ms]`;

// La función esperar cancelable de la sección 11
function esperar(ms, { signal } = {}) {
  return new Promise((resolve, reject) => {
    signal?.throwIfAborted();
    const id = setTimeout(resolve, ms);
    signal?.addEventListener('abort', () => { clearTimeout(id); reject(signal.reason); }, { once: true });
  });
}

class ErrorDescarga extends Error {
  constructor(mensaje, { archivo, intento, ...opciones } = {}) {
    super(mensaje, opciones);
    this.name = 'ErrorDescarga';
    this.archivo = archivo;
    this.intento = intento;
  }
}

// Catálogo simulado: cuánto tarda cada archivo y cuántas veces falla antes de funcionar
const catalogo = [
  { nombre: 'foto.jpg', ms: 300, fallos: 0 },
  { nombre: 'video.mp4', ms: 500, fallos: 2 },
  { nombre: 'informe.pdf', ms: 200, fallos: 5 },
];

const intentosRealizados = new Map();

// Simula una descarga: falla las primeras "fallos" veces
async function descargar(archivo, { signal } = {}) {
  const intento = (intentosRealizados.get(archivo.nombre) ?? 0) + 1;
  intentosRealizados.set(archivo.nombre, intento);
  await esperar(archivo.ms, { signal });
  if (intento <= archivo.fallos) {
    throw new ErrorDescarga(`Fallo de red al descargar ${archivo.nombre}`, { archivo: archivo.nombre, intento });
  }
  return { nombre: archivo.nombre, bytes: archivo.ms * 1024, intentos: intento };
}

// Ejecuta "tarea" y, si falla, la reintenta esperando cada vez el doble
async function conReintentos(tarea, { reintentos = 3, esperaBase = 100, signal } = {}) {
  for (let intento = 0; ; intento++) {
    try {
      return await tarea();
    } catch (error) {
      // No reintentamos si nos han cancelado o si ya no quedan intentos
      if (signal?.aborted || intento >= reintentos) throw error;
      const espera = esperaBase * 2 ** intento; // 100, 200, 400, 800...
      console.log(`${t()} ⚠ ${error.message}. Reintento en ${espera} ms`);
      await esperar(espera, { signal });
    }
  }
}

async function descargarTodo(archivos, { tiempoMaximo = 5000 } = {}) {
  const signal = AbortSignal.timeout(tiempoMaximo);
  const resultados = await Promise.allSettled(
    archivos.map(async (archivo) => {
      const datos = await conReintentos(() => descargar(archivo, { signal }), { signal });
      console.log(`${t()} ✔ ${datos.nombre} (${datos.intentos} intento/s)`);
      return datos;
    }),
  );

  const correctos = resultados.filter((r) => r.status === 'fulfilled').map((r) => r.value);
  const fallidos = resultados.filter((r) => r.status === 'rejected').map((r) => r.reason);
  return { correctos, fallidos };
}

console.log(`${t()} Empiezan ${catalogo.length} descargas en paralelo`);
const { correctos, fallidos } = await descargarTodo(catalogo);

console.log(`${t()} Terminado: ${correctos.length} correctas, ${fallidos.length} fallidas`);
const totalKb = correctos.reduce((suma, d) => suma + d.bytes, 0) / 1024;
console.log(`Descargados ${totalKb} KB`);
for (const error of fallidos) {
  console.log(`✘ ${error.archivo}: ${error.message} (último intento: ${error.intento})`);
}

Salida real con node descargas.mjs (los tiempos pueden variar en ±100 ms):

[   0 ms] Empiezan 3 descargas en paralelo
[ 200 ms] ⚠ Fallo de red al descargar informe.pdf. Reintento en 100 ms
[ 300 ms] ✔ foto.jpg (1 intento/s)
[ 500 ms] ⚠ Fallo de red al descargar video.mp4. Reintento en 100 ms
[ 500 ms] ⚠ Fallo de red al descargar informe.pdf. Reintento en 200 ms
[ 900 ms] ⚠ Fallo de red al descargar informe.pdf. Reintento en 400 ms
[1100 ms] ⚠ Fallo de red al descargar video.mp4. Reintento en 200 ms
[1800 ms] ✔ video.mp4 (3 intento/s)
[1800 ms] Terminado: 2 correctas, 1 fallidas
Descargados 800 KB
✘ informe.pdf: Fallo de red al descargar informe.pdf (último intento: 4)

Repasa qué concepto aparece en cada pieza: esperar es un setTimeout promisificado y cancelable; descargar usa async/await y rechaza con un error personalizado; conReintentos combina try/catch, return await y backoff; map(async ...) + Promise.allSettled ejecuta en paralelo tolerando fallos; AbortSignal.timeout pone un tiempo máximo global, y el await descargarTodo(...) final es top-level await.

✏️ Ejercicio: Cambia tiempoMaximo a 1000 y vuelve a ejecutarlo. ¿Qué descargas terminan? ¿Qué nombre de error tienen las demás? Después añade a conReintentos un “jitter” (un pequeño retardo aleatorio sumado a la espera) para que muchos clientes no reintenten exactamente a la vez.


14. Ejercicios

Ejercicio 1: Predice el orden

Sin ejecutarlo, escribe en un papel el orden de salida del siguiente programa. Luego compruébalo con Node y explica cada línea según las reglas del event loop.

console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C')).then(() => console.log('D'));
queueMicrotask(() => console.log('E'));
setTimeout(() => Promise.resolve().then(() => console.log('F')), 0);
console.log('G');

Ejercicio 2: Cuenta atrás

Crea una función cuentaAtras(segundos) que devuelva una promesa. Debe mostrar por consola cada segundo restante con setInterval y cumplirse cuando llegue a 0 (limpiando el intervalo). Úsala con await cuentaAtras(5); console.log('¡Tiempo!');.

Ejercicio 3: Promisificar

Dada la función con callback leerTemperatura(ciudad, callback) que, tras 300 ms, llama a callback(null, grados) o a callback(new Error('Ciudad desconocida')) si la ciudad no está en un objeto { Madrid: 31, Oslo: 12 }, escribe la versión leerTemperaturaAsync(ciudad) que devuelva una promesa. Hazlo primero a mano y después con promisify de node:util.

Ejercicio 4: Paralelo frente a secuencial

Con leerTemperaturaAsync del ejercicio anterior, obtén la temperatura de cinco ciudades: primero en secuencia con for...of y después en paralelo con Promise.all. Mide ambos tiempos con console.time. Luego usa Promise.allSettled para que una ciudad desconocida no impida mostrar las demás.

Ejercicio 5: Tiempo máximo con race

Implementa conTiempoMaximo(promesa, ms) que devuelva una promesa que se rechaza con un Error('Tiempo agotado') si promesa no termina en ms milisegundos. Usa Promise.race. Después reescríbela para que reciba una función que acepte una signal y use AbortSignal.timeout(ms), de modo que la operación lenta se cancele de verdad.

Ejercicio 6: Semáforo con generador asíncrono

Escribe un generador asíncrono semaforo() que emita indefinidamente 'verde', 'ámbar' y 'rojo', esperando 3 s, 1 s y 2 s respectivamente entre cada cambio. Recórrelo con for await...of y detente (break) tras dos ciclos completos. Después usa Array.fromAsync con un generador finito (un solo ciclo) para obtener un array con los tres colores.

Ejercicio 7: Cola de tareas cancelable

Crea una función procesarCola(tareas, { signal }) que reciba un array de funciones asíncronas y las ejecute de una en una, devolviendo un array con sus resultados. Si la señal se aborta, debe dejar de procesar en cuanto termine la tarea actual y rechazar con signal.reason. Pruébala con un AbortController que se aborte a los 2 segundos.

Ejercicio 8: Reintentos genéricos

Mejora conReintentos del ejemplo completo para que acepte una opción debeReintentar(error) (por defecto, reintentar siempre) y un maximoEspera que limite el backoff. Por ejemplo, que no reintente si el error es un TypeError y que nunca espere más de 2 segundos entre intentos.


Resumen

Concepto Sintaxis / clave
Asincronía Lanzar una operación lenta y seguir; un solo hilo que no se bloquea
Event loop Código síncrono → todas las microtareas → repintado → una macrotarea
Macrotareas setTimeout, setInterval, eventos, mensajes
Microtareas then/catch/finally, continuación tras await, queueMicrotask
Temporizadores setTimeout(fn, ms) / clearTimeout(id), setInterval / clearInterval
Callback Función que se ejecuta al terminar; convención error-first (error, dato)
Promesa Estados pendiente → cumplida o rechazada; new Promise((resolve, reject) => ...)
Consumir .then(valor => ...), .catch(error => ...), .finally(() => ...)
Promesas ya asentadas Promise.resolve(valor), Promise.reject(error)
Promisificar new Promise envolviendo el callback, promisify de node:util
Combinadores all (todas o ninguna), allSettled (todas con su estado), any (la primera que funcione; AggregateError si todas fallan), race (la primera en terminar)
Resolver desde fuera const { promise, resolve, reject } = Promise.withResolvers() (ES2024)
Capturar errores síncronos Promise.try(fn, ...args) (ES2025)
async/await async function f() { const x = await promesa; }
Errores con await try { await ... } catch (e) { ... } finally { ... }
Paralelo await Promise.all(lista.map(fn)); nunca forEach con async
Top-level await await en el nivel superior de un módulo ES
Generador asíncrono async function* gen() { yield await ... } + for await (const x of gen())
Recoger en array await Array.fromAsync(iterableAsincrono, mapeo?) (ES2026)
Cancelar new AbortController(), controlador.abort(), { signal }
Tiempo máximo AbortSignal.timeout(ms), combinar con AbortSignal.any([...])
Rechazos sin gestionar unhandledrejection (navegador), process.on('unhandledRejection') (Node)