learning-front

Nivel 3 · JavaScript moderno y asíncrono

Más promesas y cancelación

Más allá de Promise.all: esperar a todas sin caer al primer fallo (allSettled), competir por la más rápida (race, any), cancelar con AbortController y cargar módulos bajo demanda con import() dinámico.

Ya sabes lo esencial de las promesas y conoces Promise.all para lanzar varias a la vez. Pero Promise.all tiene una pega importante —si una falla, se cae todo— y hay más formas de combinar promesas que encajan mejor según el caso: esperar a todas sin rendirse al primer fallo, quedarte con la más rápida, o cancelar una petición que ya no interesa. Aquí cerramos el tema con las herramientas que usa el código real.

Seguimos con el Team Builder. Vamos a cargar el winrate de varios héroes a la vez, sabiendo que alguna carga puede fallar o tardar demasiado, y a reaccionar sin que se nos caiga la pantalla.

Recordatorio: Promise.all es “todo o nada”#

Promise.all ya lo viste a fondo en Promesas y async/await: recibe un array de promesas, las espera todas y se resuelve con un array de resultados en el mismo orden de entrada. Aquí solo necesitas recordar su pega, que es justo el problema que vamos a resolver: si una sola rechaza, Promise.all rechaza entero y pierdes también las que sí habían llegado (un await Promise.all([...]) cae directo al catch, sin ningún dato).

Para “todo o nada” está bien (o cargan todos los datos críticos o no sigues). Pero muchas veces quieres lo contrario: lo que llegue, que llegue.

Promise.allSettled: espera a todas, nunca rechaza#

Promise.allSettled espera a que todas terminen —cumplan o fallen— y nunca rechaza. Te da un array donde cada elemento describe su resultado:

Un detalle importante: Promise.all (y también Promise.allSettled) devuelve los resultados en el mismo orden del array de entrada, no en el orden en que se resolvieron. Si pasas [cargarTracer, cargarMercy, cargarGenji], el array de vuelta siempre será [resultadoTracer, resultadoMercy, resultadoGenji], aunque Genji llegue primero. Puedes confiar en ese orden para saber qué resultado le corresponde a qué héroe.

  • { status: "fulfilled", value } si la promesa cumplió.
  • { status: "rejected", reason } si falló.
javascript
const resultados = await Promise.allSettled([
  cargarWinrate("Tracer"),
  // esta falla, pero no tumba al resto
  cargarWinrate("Bastion"),
  cargarWinrate("Mercy"),
]);

for (const r of resultados) {
  // Cada uno trae su status: decidimos qué hacer con cada caso.
  if (r.status === "fulfilled") console.log("OK: " + r.value);
  else console.log("Falló: " + r.reason.message);
}

Es la herramienta para carga resiliente: pides varias cosas y pintas las que lleguen, marcando las que no. Un fallo aislado no arruina la pantalla.

Promise.race: la primera en terminar (patrón timeout)#

Promise.race se resuelve (o rechaza) con la primera promesa que termine, gane o falle. Su uso estrella es ponerle un límite de tiempo a algo: corres la operación contra un temporizador que rechaza al pasar el tiempo, y gana lo que ocurra antes.

javascript
// Un temporizador que rechaza pasados `ms` milisegundos.
function timeout(ms) {
  return new Promise((_, reject) => {
    setTimeout(() => reject(new Error("demasiado lento")), ms);
  });
}

// O llegan los datos antes de 1 s, o salta el timeout. Lo que pase primero.
const datos = await Promise.race([cargarWinrate("Genji"), timeout(1000)]);

Si cargarWinrate tarda menos de un segundo, race se resuelve con sus datos. Si tarda más, gana el timeout y race rechaza: lo tratas como cualquier error.

Promise.any: la primera que CUMPLA#

Promise.any se parece a race, pero con un matiz clave: ignora los fallos y se queda con la primera que cumple. Solo rechaza si todas fallan.

javascript
// Varias réplicas del mismo servidor; nos vale la primera que responda bien.
const winrate = await Promise.any([
  cargarDe("servidor-1"),
  // si el 1 falla, seguimos esperando a este
  cargarDe("servidor-2"),
  cargarDe("servidor-3"),
]);
// Si TODAS fallan, any rechaza con un AggregateError (la suma de los fallos).

La diferencia con race: race se queda con la primera que termine (aunque sea un fallo); any espera hasta tener un éxito. Úsalo cuando tienes varias fuentes equivalentes y te basta la primera que funcione.

Ejemplo ejecutable con el roster: dos cargas fallan antes de que llegue una válida, y any nos da esa única que funcionó:

javascript
// Simulamos tres cargas: Bastion y Sigma fallan, Tracer responde bien.
function cargarWinrate(nombre) {
  return new Promise(function(resolve, reject) {
    // solo Tracer está en la base
    var tabla = { Tracer: 0.65 };
    setTimeout(function() {
      if (tabla[nombre] !== undefined) {
        // cumple con el valor
        resolve(nombre + ': ' + Math.round(tabla[nombre] * 100) + '%');
      } else {
        // falla: no está en la tabla
        reject(new Error('sin datos de ' + nombre));
      }
    }, 200);
  });
}

Promise.any([
  // falla: no está en la tabla
  cargarWinrate('Bastion'),
  // falla: tampoco está
  cargarWinrate('Sigma'),
  // cumple: devuelve "Tracer: 65%"
  cargarWinrate('Tracer'),
])
  .then(function(resultado) {
    // any ignora los dos fallos anteriores y llega aquí con el primero que cumplió
    // "any: primer exito -> Tracer: 65%"
    console.log('any: primer exito -> ' + resultado);
  })
  .catch(function(error) {
    // solo entraría aquí si Bastion, Sigma Y Tracer hubieran fallado todos
    console.log('any: fallaron todas -> ' + error.message);
  });

// ── Caso límite: TODAS fallan -> AggregateError ────────────────────────────
// Si ninguna cumple, any rechaza con un AggregateError: contiene todos los fallos.
Promise.any([
  // falla: no está en la tabla
  cargarWinrate('Bastion'),
  // falla: tampoco está
  cargarWinrate('Sigma'),
  // falla: tampoco
  cargarWinrate('Doomfist'),
])
  .then(function(resultado) {
    // no debería llegar aquí: todas fallan
    console.log('ganó alguna: ' + resultado);
  })
  .catch(function(error) {
    // AggregateError acumula todos los rechazos en error.errors
    // "sin datos de Bastion, sin datos de Sigma, sin datos de Doomfist"
    console.log('todas fallaron: ' + error.errors.map(function(e) { return e.message; }).join(', '));
  });

AbortController: cancelar lo que ya no interesa#

A veces lanzas una petición y, antes de que termine, deja de importarte: el usuario reescribe la búsqueda, cambia de pantalla, cierra el componente. Dejarla correr gasta red y puede pintar datos viejos. Para cancelar está AbortController:

javascript
// el "mando a distancia" para cancelar
const controlador = new AbortController();

// Le pasamos su signal al fetch: queda atado a este controlador.
fetch("https://api.tuequipo.dev/heroes", { signal: controlador.signal })
  .then((res) => res.json())
  .catch((error) => {
    // Al cancelar, el fetch rechaza con un AbortError: lo distinguimos.
    if (error.name === "AbortError") console.log("Cancelado a propósito");
    else console.log("Otro fallo: " + error.message);
  });

// En cuanto ya no interesa (nueva búsqueda, componente desmontado…):
// la petición se cancela y su promesa rechaza
controlador.abort();

controlador.abort() cancela la petición en curso. Lo combinarás muchísimo cuando llegues a React: cancelar el fetch de un efecto cuando el componente se desmonta o cuando cambian sus datos.

Cuándo cada uno#

CombinadorEspera a…Resultado
Promise.alltodasarray de valores; rechaza con el primer fallo
Promise.allSettledtodasarray con el status de cada una; nunca rechaza
Promise.racela primera en terminarse resuelve o rechaza con esa, gane o falle
Promise.anyla primera que cumplaesa; rechaza solo si fallan todas (AggregateError)

Y AbortController no combina promesas: cancela una operación en curso. Se usa junto a fetch.

import() dinámico: cargar un módulo bajo demanda#

En el capítulo de Módulos quedó algo pendiente: el import() con paréntesis. Ya puedes entenderlo, porque es justo una promesa. El import normal (estático) se resuelve al cargar el fichero, siempre, arriba del todo. import() es una función que devuelve una promesa del módulo: carga ese código solo cuando lo llamas.

javascript
async function mostrarGraficas() {
  // El módulo de gráficas (pesado) NO se carga al arrancar la página…
  // …sino aquí, bajo demanda
  const graficas = await import("./graficas.js");
  // usamos lo que exporta, ya cargado
  graficas.dibujar();
}

¿Para qué? Para no cargar de golpe código que quizá no se use. Es la base del code-splitting (partir el bundle en trozos que se cargan cuando hacen falta) y del lazy loading de React (que verás en su nivel: React.lazy carga un componente solo al mostrarlo). Una página que arranca ligera y trae lo pesado cuando toca.

Una nota relacionada: el top-level await. En un módulo puedes usar await en el nivel superior, sin envolverlo en una función async:

javascript
// En un módulo, esto es válido directamente (sin función async alrededor):
const config = await import("./config.js");

Es cómodo para inicializar un módulo con algo asíncrono, pero úsalo con cabeza: ese await retrasa la carga del módulo que lo usa.

Pruébalo tú#

Edita el código y pulsa Ejecutar (o Ctrl+Enter): compara cómo all se cae entero por Bastion mientras allSettled salva al resto, y cómo race resuelve con la carga antes de que salte el timeout. Luego baja el timeout de 1000 a 100 y observa cómo ahora gana el temporizador.

Comprueba lo que sabes#

Pregunta 1 de 5

Cargas 5 recursos y quieres pintar los que lleguen aunque alguno falle. ¿Promise.all o Promise.allSettled?

Tu turno#

Carga el winrate de un roster donde uno falla y otro va muy lento. No pierdas lo que sí llega y, en los niveles altos, ponle un timeout al lento y cancélalo. Cuando lo tengas (o si te atascas), despliega las soluciones y fíjate en cómo el nivel Excelente combina allSettled, race y AbortController para una carga resiliente, acotada y limpia.

Ejercicio · en esta página

Carga resiliente del roster

Carga el winrate de seis héroes a la vez. Bastion falla (no está en la base) y Reaper va muy lento. No dejes que un fallo se lleve por delante a los que sí llegan; en los niveles altos, ponle un timeout al lento y cancela su carga. Muestra los resultados por la consola.

Paso 1: Que funcione

  • Cargas los winrates en paralelo y muestras el resultado en la consola.
  • Vale usar Promise.all (aunque un fallo tumbe la carga entera).
Ver soluciones
// ════════════════════════════════════════════════════════════════════════════
// NIVEL OK — "funciona"
//
// Carga todos los winrates en paralelo con Promise.all. Cuando TODO va bien, es
// perfecto: tardas lo de la más lenta, no la suma.
//
// Su límite: Promise.all es "todo o nada". En cuanto Bastion falla, all rechaza
// ENTERO y el catch se traga también a Tracer, Mercy, Genji y Ana, que sí habían
// llegado. Una sola pieza rota deja la consola sin resultados. Para datos que
// pueden fallar por separado, all es demasiado frágil.
// ════════════════════════════════════════════════════════════════════════════

var TABLA = {
  Tracer: { winrate: 0.65, ms: 250 },
  Mercy: { winrate: 0.68, ms: 400 },
  Genji: { winrate: 0.48, ms: 300 },
  Ana: { winrate: 0.63, ms: 500 },
  Reaper: { winrate: 0.55, ms: 1500 },
};
var HEROES = ["Tracer", "Mercy", "Bastion", "Genji", "Ana", "Reaper"];

function cargarWinrate(nombre) {
  return new Promise(function (resolve, reject) {
    var dato = TABLA[nombre];
    setTimeout(
      function () {
        if (dato) resolve({ nombre: nombre, winrate: dato.winrate });
        else reject(new Error("sin datos de " + nombre));
      },
      dato ? dato.ms : 200,
    );
  });
}

async function cargarRoster() {
  console.log("Cargando winrates…");
  try {
    // todas a la vez; si UNA rechaza (Bastion), all rechaza entero
    var resultados = await Promise.all(
      HEROES.map(function (n) {
        return cargarWinrate(n);
      }),
    );
    resultados.forEach(function (r) {
      console.log(r.nombre + ": " + Math.round(r.winrate * 100) + "%");
    });
  } catch (error) {
    // cae aquí por Bastion, y perdemos también a los que sí llegaron
    console.log("No se pudo cargar el roster: " + error.message);
  }
}

cargarRoster();

Por qué este nivel

  • Promise.all carga todos los winrates en paralelo: cuando todo va bien, es perfecto (tardas lo de la más lenta, no la suma).
  • Su límite: all es 'todo o nada'. En cuanto Bastion falla, rechaza entero y el catch se traga también a los que sí llegaron.
  • Una pieza rota deja la pantalla vacía: demasiado frágil para datos que pueden fallar por separado.