Promise.all en Node.js: ejecutar tareas en paralelo

Cómo usar Promise.all en Node.js para hacer varias cosas a la vez, y qué usar cuando una de ellas puede fallar o tardar demasiado.

Cuando empiezas con async/await es fácil escribir un await tras otro y que todo funcione. Funciona, sí, pero muchas veces estás esperando de más: tu código hace una consulta, espera, hace la siguiente, espera, y así. Si esas consultas no dependen una de otra, podrían ir al mismo tiempo.

Aquí vemos cómo usar Promise.all en Node.js para ejecutar tareas en paralelo, qué hacer cuando una puede fallar con Promise.allSettled, cómo quedarte con la más rápida con race y any, y cómo ponerles un tiempo límite. Si todavía no te queda claro qué hace await, empieza por async/await en Node.js.

Probé todo el código con Node.js 22. Los ejemplos usan módulos ES, así que pon "type": "module" en tu package.json (o usa archivos .mjs).

Secuencial o paralelo

Imagina que necesitas tres datos que no dependen uno del otro: el usuario, sus pedidos y sus pagos. Cada consulta tarda un segundo. Las simulamos con una espera:

import { setTimeout as esperar } from 'node:timers/promises';

// Simulamos tres consultas que tardan 1 segundo cada una
async function consultar(nombre) {
  await esperar(1000);
  return `${nombre} listo`;
}

async function enSecuencia() {
  const inicio = Date.now();
  const usuario = await consultar('usuario');
  const pedidos = await consultar('pedidos');
  const pagos = await consultar('pagos');
  console.log('En secuencia :', [usuario, pedidos, pagos], `${Date.now() - inicio} ms`);
}

async function enParalelo() {
  const inicio = Date.now();
  const [usuario, pedidos, pagos] = await Promise.all([
    consultar('usuario'),
    consultar('pedidos'),
    consultar('pagos'),
  ]);
  console.log('En paralelo  :', [usuario, pedidos, pagos], `${Date.now() - inicio} ms`);
}

await enSecuencia();
await enParalelo();

Resultado:

En secuencia : [ 'usuario listo', 'pedidos listo', 'pagos listo' ] 3004 ms
En paralelo  : [ 'usuario listo', 'pedidos listo', 'pagos listo' ] 1002 ms

Con tres await seguidos, cada consulta espera a que termine la anterior y los tiempos se suman. Con Promise.all, las tres arrancan al mismo tiempo y el total es lo que tarda la más lenta. Recibes los resultados en un arreglo, en el mismo orden en que pusiste las promesas.

La regla es sencilla: si una consulta necesita el resultado de la anterior, usa await seguidos. Si no depende de nada, ponlas en un Promise.all.

Cuando una de varias falla

Promise.all tiene una condición: si una sola promesa falla, todo el Promise.all falla. A veces es lo que quieres. Otras veces, no. Para cuando no, existe Promise.allSettled, que espera a todas y te dice cómo le fue a cada una:

import { setTimeout as esperar } from 'node:timers/promises';

async function ok(nombre, ms) {
  await esperar(ms);
  return nombre;
}

async function falla(nombre, ms) {
  await esperar(ms);
  throw new Error(`${nombre} falló`);
}

// Promise.all: si una falla, se rechaza todo
try {
  const r = await Promise.all([ok('usuario', 100), falla('pagos', 200), ok('pedidos', 300)]);
  console.log('all ->', r);
} catch (err) {
  console.log('all -> error:', err.message);
}

// Promise.allSettled: esperas todas y revisas una por una
const resultados = await Promise.allSettled([
  ok('usuario', 100),
  falla('pagos', 200),
  ok('pedidos', 300),
]);

for (const r of resultados) {
  if (r.status === 'fulfilled') {
    console.log('allSettled -> ok    :', r.value);
  } else {
    console.log('allSettled -> falló :', r.reason.message);
  }
}
console.log(resultados[0]);

Salida:

all -> error: pagos falló
allSettled -> ok    : usuario
allSettled -> falló : pagos falló
allSettled -> ok    : pedidos
{ status: 'fulfilled', value: 'usuario' }

Cada resultado de allSettled es un objeto con status. Si es 'fulfilled' tiene el value, y si es 'rejected' tiene la reason (el error). Úsalo cuando quieras aprovechar lo que sí salió bien. Por ejemplo, mostrar una página aunque el bloque de pagos no haya respondido.

Promise.race y Promise.any

Hay dos formas más de combinar promesas, y se parecen tanto que se confunden:

  • Promise.race: gana la primera que termine, sea éxito o error.
  • Promise.any: gana la primera que salga bien. Ignora las que fallan, y solo falla si fallan todas.
import { setTimeout as esperar } from 'node:timers/promises';

async function servidor(nombre, ms, falla = false) {
  await esperar(ms);
  if (falla) throw new Error(`${nombre} no respondió`);
  return nombre;
}

// race: gana la primera que termine, sea éxito o error
try {
  const r = await Promise.race([servidor('A', 300), servidor('B', 100, true)]);
  console.log('race ->', r);
} catch (err) {
  console.log('race -> error:', err.message);
}

// any: gana la primera que salga bien, ignora las que fallan
const r = await Promise.any([servidor('A', 300), servidor('B', 100, true)]);
console.log('any ->', r);

// any: si todas fallan
try {
  await Promise.any([servidor('A', 100, true), servidor('B', 200, true)]);
} catch (err) {
  console.log('any (todas fallaron) ->', err.name, '-', err.errors.map((e) => e.message));
}
race -> error: B no respondió
any -> A
any (todas fallaron) -> AggregateError - [ 'A no respondió', 'B no respondió' ]

Con race, el servidor B respondió primero, pero con un error, y ese error fue el resultado. Con any, se ignoró ese fallo y ganó A. Si pides el mismo dato a dos servidores y te sirve el primero que conteste bien, any es lo que buscas. Cuando todas fallan, recibes un AggregateError con la lista de errores en errors.

Ponerle un límite de tiempo

Una promesa que nunca se resuelve deja tu código esperando para siempre. Con servicios externos conviene poner un tiempo máximo. La forma más limpia es AbortSignal.timeout:

import { setTimeout as esperar } from 'node:timers/promises';

async function tareaLenta(senal) {
  // se cancela si la señal se activa
  await esperar(3000, undefined, { signal: senal });
  return 'terminó';
}

try {
  const resultado = await tareaLenta(AbortSignal.timeout(500));
  console.log(resultado);
} catch (err) {
  console.log('nombre:', err.name);
  console.log('mensaje:', err.message);
}
nombre: AbortError
mensaje: The operation was aborted

A los 500 ms la señal se activa y la espera se cancela con un AbortError. Muchas funciones de Node aceptan una señal (fetch, fs/promises, los timers), así que el patrón sirve en más lugares.

Procesar una lista grande en lotes

Promise.all es muy cómodo, pero si lo lanzas con miles de elementos todo arranca a la vez: miles de consultas a tu base de datos o miles de peticiones a una API. Para eso conviene procesar en grupos:

import { setTimeout as esperar } from 'node:timers/promises';

async function procesar(id) {
  await esperar(100);
  return id;
}

// Procesa la lista en grupos de "tamano", un grupo a la vez
async function enLotes(lista, tamano, tarea) {
  const resultados = [];
  for (let i = 0; i < lista.length; i += tamano) {
    const lote = lista.slice(i, i + tamano);
    const parcial = await Promise.all(lote.map(tarea));
    resultados.push(...parcial);
  }
  return resultados;
}

const ids = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const inicio = Date.now();
const r = await enLotes(ids, 3, procesar);
console.log(r, `${Date.now() - inicio} ms`);
[
  1, 2, 3, 4, 5,
  6, 7, 8, 9
] 302 ms

Nueve tareas de 100 ms tardaron unos 300 ms: tres grupos de tres, cada grupo en paralelo. Con todo en un solo Promise.all habrían sido unos 100 ms, pero sin ningún control sobre cuántas corren a la vez. Esta misma idea es la que usamos al guardar filas en la base de datos en el artículo de leer CSV en Node.js.

Resultado: Promise.all en Node.js con cuatro consultas

Juntemos todo en un ejemplo: cuatro consultas en paralelo, cada una con un máximo de un segundo, donde una falla y otra tarda demasiado. Queremos un reporte de cuáles salieron bien sin que una tire a las demás.

import { setTimeout as esperar } from 'node:timers/promises';

// Simula un servicio externo. Algunos tardan, otros fallan.
async function consultar(nombre, ms, { falla = false, senal } = {}) {
  await esperar(ms, undefined, { signal: senal });
  if (falla) throw new Error('el servicio respondió con error');
  return 'ok';
}

// Cada consulta tiene un máximo de 1 segundo
const conLimite = (nombre, ms, opciones) =>
  consultar(nombre, ms, { ...opciones, senal: AbortSignal.timeout(1000) });

const nombres = ['usuario', 'pedidos', 'pagos', 'inventario'];
const inicio = Date.now();

const resultados = await Promise.allSettled([
  conLimite('usuario', 300),
  conLimite('pedidos', 500),
  conLimite('pagos', 200, { falla: true }),
  conLimite('inventario', 3000), // este se pasa del límite
]);

for (const [i, r] of resultados.entries()) {
  if (r.status === 'fulfilled') {
    console.log(`✔ ${nombres[i]}: ${r.value}`);
  } else if (r.reason.name === 'AbortError') {
    console.log(`✘ ${nombres[i]}: se pasó del tiempo límite`);
  } else {
    console.log(`✘ ${nombres[i]}: ${r.reason.message}`);
  }
}

console.log(`Terminó en ${Date.now() - inicio} ms`);

La consola muestra:

✔ usuario: ok
✔ pedidos: ok
✘ pagos: el servicio respondió con error
✘ inventario: se pasó del tiempo límite
Terminó en 1016 ms

Las cuatro consultas corrieron a la vez, así que el total fue de poco más de un segundo, que es el límite que le pusimos a la más lenta. Sin ese límite habríamos esperado los 3 segundos que tarda inventario. Y como usamos allSettled, el error de pagos y el tiempo agotado de inventario no impidieron que usuario y pedidos nos dieran su resultado.

Errores comunes

1. Usar async dentro de un forEach

forEach no sabe esperar promesas. Lanza todas y sigue con lo que viene después:

import { setTimeout as esperar } from 'node:timers/promises';

const ids = [1, 2, 3];

async function guardar(id) {
  await esperar(100);
  console.log('guardado', id);
}

console.log('--- con forEach ---');
ids.forEach(async (id) => {
  await guardar(id);
});
console.log('forEach terminó (y no se guardó nada todavía)');

await esperar(500);

console.log('--- con for...of ---');
for (const id of ids) {
  await guardar(id);
}
console.log('for...of terminó (ya se guardó todo)');
--- con forEach ---
forEach terminó (y no se guardó nada todavía)
guardado 1
guardado 2
guardado 3
--- con for...of ---
guardado 1
guardado 2
guardado 3
for...of terminó (ya se guardó todo)

Con forEach, el programa sigue de largo y el guardado ocurre después, sin que nadie lo espere. Si algo falla ahí dentro, ningún try/catch tuyo lo atrapa. Usa for...of para ir uno por uno, o Promise.all con .map si pueden ir en paralelo.

2. Usar map sin Promise.all

Es el hermano del anterior. Si usas .map con una función async, lo que obtienes es un arreglo de promesas, no de valores:

import { setTimeout as esperar } from 'node:timers/promises';

const ids = [1, 2, 3];

async function doble(id) {
  await esperar(100);
  return id * 2;
}

// Sin Promise.all: obtienes promesas, no valores
const mal = ids.map(doble);
console.log('sin Promise.all ->', mal);

// Con Promise.all: obtienes los valores
const bien = await Promise.all(ids.map(doble));
console.log('con Promise.all ->', bien);
sin Promise.all -> [ Promise { <pending> }, Promise { <pending> }, Promise { <pending> } ]
con Promise.all -> [ 2, 4, 6 ]

3. Creer que Promise.all cancela a las demás

Cuando una promesa falla en un Promise.all, las otras siguen corriendo. Solo dejas de esperarlas:

import { setTimeout as esperar } from 'node:timers/promises';

async function tarea(nombre, ms, falla = false) {
  await esperar(ms);
  if (falla) throw new Error(`${nombre} falló`);
  console.log(`${nombre} terminó`);
}

try {
  await Promise.all([
    tarea('A', 100, true),
    tarea('B', 300),
  ]);
} catch (err) {
  console.log('Promise.all se rechazó:', err.message);
}

await esperar(500);
console.log('fin');

Salida:

Promise.all se rechazó: A falló
B terminó
fin

Aquí la tarea B terminó después de que Promise.all ya había fallado. Si B escribía en la base de datos, esa escritura sí ocurrió. Tenlo presente cuando lances varias operaciones que modifican datos.

(Si tienes alguna anécdota real con operaciones en paralelo en producción, este es el lugar para contarla en uno o dos párrafos. Por ejemplo, una carga que se volvió lenta por hacerlo todo en secuencia, o una API que te bloqueó por lanzar demasiadas peticiones juntas.)

Recomendaciones

  • Antes de poner varios await seguidos, pregúntate si dependen uno del otro. Si no, Promise.all te ahorra tiempo.
  • Si quieres aprovechar lo que sí funcionó, usa Promise.allSettled. Si cualquier fallo invalida todo el resultado, Promise.all está bien.
  • Cuando vayas a usar Promise.all en Node.js con muchos elementos, no los lances todos a la vez: procésalos por lotes.
  • Ponle siempre un tiempo máximo a las llamadas a servicios externos. Un servicio lento sin límite puede dejar colgado todo tu servidor.
  • Los errores merecen su propio cuidado. Mira cómo hacer el manejo de errores en Node.js para que un fallo no tumbe tu aplicación.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *