Elegir entre Promise.all, Promise.race y esperas secuenciales
Aprenda cuándo Promise.all() acelera las APIs de Node.js, por qué falla rápidamente ante cualquier rechazo, y un marco de toma de decisiones para elegir el patrón asíncrono adecuado.
Ejecutar tareas asíncronas en Node.js a menudo parece ser una solución sencilla: realizar varias operaciones al mismo tiempo en lugar de esperarlas secuencialmente, lo que hace que la API sea más rápida. Sin embargo, en la práctica, tratar Promise.all() como un hábito habitual en lugar de una elección deliberada puede convertir silenciosamente ese aumento de velocidad en un problema de fiabilidad. Saber cuándo es seguro paralelizar las operaciones, qué debe ocurrir si una de ellas falla y cuándo es más adecuado utilizar otra herramienta es tan importante como conocer la sintaxis.
1. El enfoque secuencial
Imaginemos una API que debe reunir tres cosas:
- Información del usuario
- Pedidos
- Pagos
Un primer enfoque ingenuo podría verse así:
const user = await getUser(userId);
const orders = await getOrders(userId);
const payments = await getPayments(userId);
return {
user,
orders,
payments
};
Este código funciona bien. Pero observe con atención el orden de ejecución:
getUser()
↓
getOrders()
↓
getPayments()
Cada paso espera a que termine el anterior. La solicitud de pedidos no puede comenzar hasta que se resuelva la solicitud de usuarios, y la solicitud de pagos no puede comenzar hasta que se resuelva la solicitud de pedidos. Si cada llamada tarda aproximadamente lo mismo:
getUser() = 200ms
getOrders() = 200ms
getPayments() = 200ms
entonces el tiempo total de solicitud suma algo como:
200 + 200 + 200 = 600ms
Eso es tiempo desperdiciado si estas tres llamadas no tienen nada que ver entre sí.
2. Utilizar Promise.all()
Cuando las operaciones no dependen unas de otras, puedes ejecutarlas simultáneamente en su lugar:
const [user, orders, payments] = await Promise.all([
getUser(userId),
getOrders(userId),
getPayments(userId)
]);
return {
user,
orders,
payments
};
El flujo de ejecución ahora se parece más a esto:
getUser() ────────┐
│
getOrders() ────────┤
├──→ Promise.all()
getPayments() ────────┘
En lugar de incurrir en el costo de:
A + B + C
tu tiempo total de espera será aproximadamente:
max(A, B, C)
Así que si cada una de las tres llamadas sigue tardando unos 200 ms:
Sequential: ~600ms
Parallel: ~200ms
Eso representa una mejora significativa. Pero hay un matiz que vale la pena recordar:
Promise.all() no acelera ninguna operación individual.
Simplemente permite que las operaciones independientes se ejecuten de forma concurrente en lugar de una tras otra.
3. Un ejemplo de API del mundo real
Considere una pantalla de panel de control que necesita renderizar:
Profile
Orders
Wishlist
Notifications
Estos podrían corresponder a cuatro búsquedas en la base de datos o llamadas al servicio separadas. Escritas secuencialmente, podrían verse así:
const profile = await getUserProfile(userId);
const orders = await getUserOrders(userId);const wishlist = await getUserWishlist(userId);const notifications = await getUserNotifications(userId);return {
profile,
orders,
wishlist,
notifications
};
Ahora comparemos eso con la versión paralela:
const [
profile,
orders,
wishlist,
notifications
] = await Promise.all([
getUserProfile(userId),
getUserOrders(userId),
getUserWishlist(userId),
getUserNotifications(userId)
]);
return {
profile,
orders,
wishlist,
notifications
}
Suponiendo que esas cuatro llamadas realmente no dependan unas de otras, esta reescritura puede reducir notablemente la latencia total de la API. Es una de las mejoras más sencillas disponibles al optimizar el rendimiento de Node.js.
Pero esto no es el final de la historia: hay un detalle que debes entender antes de aplicar este patrón en todas partes.
4. Promise.all() falla rápidamente
Aquí está la parte que confunde a la gente. Toma este ejemplo:
const results = await Promise.all([
getUser(),
getOrders(),
getPayments()
]);
¿Qué sucede si getPayments() lanza una rechazo?
Toda la llamada a Promise.all() se rechaza, sin importar cómo hayan ido las demás llamadas:
getUser() → SUCCESS
getOrders() → SUCCESS
getPayments() → ERROR
↓ Promise.all()
↓
REJECT
No obtienes un resultado parcial con las dos llamadas exitosas ni un marcador para la que falló. Obtienes una excepción, y no llega ningún dato:
try {
const [user, orders, payments] = await Promise.all([
getUser(userId),
getOrders(userId),
getPayments(userId)
]);
} catch (error) {
console.error(error);
}
Este comportamiento de todo-o-nada tiene sentido cuando cada operación del grupo es necesaria para que la respuesta sea válida. Pero no siempre es la opción adecuada.
5. Cuándo Promise.all() no es la elección correcta
Imagina que un panel de control necesita mostrar:
- Perfil
Si el servicio de recomendaciones está temporalmente fuera de servicio, ¿debería fallar la carga de todo el panel de control? Casi con certeza no. Un resultado mejor sería algo como:
Profile → Available
Notifications → Available
Recommendations → Unavailable
Esta es exactamente la situación para la cual fue creado Promise.allSettled():
const results = await Promise.allSettled([
getUserProfile(userId),
getRecommendations(userId),
getNotifications(userId)
]);
En lugar de detenerse en la primera rechazo, espera a que todas las promesas se resuelvan y reporta el estado de cada una:
[
{
status: "fulfilled",
value: profile
},
{
status: "rejected",
reason: error
},
{
status: "fulfilled",
value: notifications
}
]
A partir de ahí, la lógica de tu aplicación puede decidir cómo tratar cada resultado individual. La diferencia radica en esto:
Promise.all()
One fails
↓
Everything rejects
en comparación con:
Promise.allSettled()
One fails
↓
You still receive every result
Ningún enfoque es inherentemente superior: están diseñados para resolver problemas diferentes, y elegir el adecuado depende de si un único fallo debe considerarse fatal para todo el grupo.
6. Las operaciones en cadena no deben forzarse a ser paralelas
Hay otra trampa que merece ser señalada.
Supongamos que su flujo de trabajo requiere que:
- Cree un usuario
- Obtenga el ID de ese usuario
- Cree un pedido vinculado a ese usuario
Estos pasos dependen unos de otros.
Físicamente no es posible crear el pedido antes de que exista el registro del usuario.
Eso significa que escribir algo como esto está mal:
await Promise.all([
createUser(),
createOrder()
]);
El paso de creación del pedido probablemente necesite el ID del usuario como entrada.
El enfoque correcto es ejecutar estos pasos uno tras otro:
const user = await createUser();
const order = await createOrder(user.id);
El principio rector aquí es sencillo:
Las operaciones que no tienen relación entre sí son candidatas para su ejecución en paralelo.
Las operaciones que dependen de los resultados de otras deben ejecutarse en secuencia.
No recurra al paralelismo solo porque el lenguaje se lo permite.
7. El paralelismo ilimitado puede sobrecargar su sistema
Existe un problema más sutil que es fácil pasar por alto.
Considere este fragmento de código:
await Promise.all(
users.map(user => sendEmail(user.email))
);
Con 10 usuarios, es poco probable que esto cause problemas.
Con 10,000 usuarios, ahora se están iniciando miles de operaciones simultáneas.
Más concurrencia no se traduce automáticamente en un mejor rendimiento.
Puede encontrarse con:
- Límites en las conexiones a la base de datos
- Límites de tasa de la API
- Sobrecarga de memoria
- Congestión de red
Lo que a menudo se necesita en lugar de una ejecución paralela ilimitada es concurrency limitada.
Una forma de lograrlo es mediante una biblioteca que limite la concurrencia:
import pLimit from "p-limit";
const limit = pLimit(5);const results = await Promise.all(
users.map(user =>
limit(() => sendEmail(user.email))
)
);
Con esta configuración, no más de cinco operaciones se ejecutan al mismo tiempo.
Visualmente, la diferencia se ve así:
1000 tasks
↓Concurrency limit = 5 ↓5 tasks
5 tasks
5 tasks
5 tasks
...
Esto es más lento que ejecutar las 1,000 tareas de una sola vez.
Pero brinda un control mucho mayor.
Y en condiciones reales, la ejecución controlada suele rendir mejor en general, ya que evita saturar los recursos de los cuales dependen las operaciones.
8. Promise.race() resuelve un problema diferente
Hay otro método que se confunde con Promise.all():
Promise.race()
Promise.race() se resuelve — ya sea al cumplirse o rechazarse — en el momento en que la primera promesa del grupo se resuelve.
Por ejemplo:
const result = await Promise.race([
serverA(),
serverB()
]);
Visualmente:
Server A ───────────────→ 500ms
Server B ───────→ 200ms ↓
Promise.race()
↓
Result
Este patrón tiene usos legítimos, como competir entre solicitudes redundantes o implementar un comportamiento de tiempo de espera.
No obstante, tenga en cuenta lo siguiente:
Promise.race() no detiene automáticamente las operaciones que pierden la competencia.
Si necesita cancelar las operaciones perdedoras, debe hacerlo usted mismo, generalmente con algo como AbortController.
9. No olvide el comportamiento de reintentos
Supongamos que está realizando una llamada a un servicio externo:
const result = await paymentService();
La llamada falla debido a un problema temporal en la red.
Si envuelves todo en un gran Promise.all() y vuelves a intentarlo ciegamente ante un fallo, puedes generar un nuevo problema.
Riesgas provocar una tormenta de intentos de reintentado.
Antes de volver a intentarlo, considera:
- ¿Qué operaciones son realmente seguras para reintentar?
- ¿Cuántos intentos de reintentado se deben permitir?
- ¿Cuánto tiempo debes esperar entre intentos?
- ¿Es la operación idempotente?
- ¿Qué pasa si el servicio externo ya está lidiando con la carga?
Un patrón común para errores transitorios es el retroceso exponencial.
Conceptualmente:
Attempt 1 → fail
↓
wait
↓
Attempt 2 → fail
↓
wait longer
↓
Attempt 3 → success
La velocidad significa poco si socava la fiabilidad.
10. Aplica el mismo razonamiento a las consultas a la base de datos
Es tentador pensar que, dado que JavaScript admite promesas concurrentes, las consultas a la base de datos siempre deberían ejecutarse en paralelo.
Eso no es siempre cierto.
Tomemos este ejemplo:
await Promise.all([
database.users.findMany(),
database.orders.findMany(),
database.products.findMany(),
database.payments.findMany(),
database.notifications.findMany()
]);
Esto lanza aproximadamente cinco operaciones de base de datos al mismo tiempo.
Eso podría estar perfectamente bien.
O podría hacer que su base de datos supere sus límites durante períodos de tráfico máximo.
Factores que vale la pena considerar incluyen:
- Qué tan compleja es cada consulta
- El tamaño del pool de conexiones a la base de datos
- Cuántas instancias de API están en ejecución
- El volumen total de tráfico
- Si existen índices adecuados
- Cuánto tiempo tarda cada consulta en ejecutarse
- El espacio disponible de CPU y memoria en el servidor de la base de datos
El ajuste del rendimiento no puede realizarse de forma aislada.
Su API es solo una parte de un sistema mucho más grande.
11. Un marco para decidir cuándo usar Promise.all()
Antes de aplicar Promise.all(), es útil plantearse tres preguntas.
Pregunta 1: ¿Son independientes las operaciones?
Si lo son, ejecutarlas en paralelo podría ser beneficioso.
Si no lo son, se debe mantener el orden en el que dependen.
Pregunta 2: ¿Qué debería ocurrir si una operación falla?
Si un único fallo debe invalidar todo el lote:
Promise.all()
es probablemente la herramienta adecuada.
Si prefiere recopilar los resultados de las operaciones que funcionaron:
Promise.allSettled()
suele ser la opción más adecuada.
Pregunta 3: ¿Cuántas operaciones se ejecutan al mismo tiempo?
Tres llamadas concurrentes?
En general, eso es manejable.
Diez mil?
Eso representa un desafío completamente diferente.
Puede ser necesario introducir:
- Límites de concurrencia
- Batching
- Colas
- Paginación
- Límites de tasa
12. Una referencia rápida para elegir un enfoque
| Situación | Enfoque mejor |
|---|---|
| Operaciones independientes, todas deben tener éxito | Promise.all() |
| Operaciones independientes, está bien tener un éxito parcial | Promise.allSettled() |
| Las operaciones dependen unas de otras | await secuencial |
| Solo se necesita la que termine primero | Promise.race() |
| Muchas tareas que requieren concurrencia limitada | p-limit o agrupamiento |
| Tareas en segundo plano lentas y no urgentes | Cola o proceso de trabajo |
| Llamadas externas propensas a fallos transitorios | Lógica de reintentos con retroceso |
Lo importante no es memorizar esta tabla.
Sino comprender la lógica detrás de cada elección.
13. La conclusión principal
Cuando se encuentra por primera vez con Promise.all(), es natural asumir:
"Ejecutar tareas en paralelo siempre es más rápido."
Esa suposición no es cierta.
Una forma más precisa de pensar en ello es:
La ejecución paralela solo es beneficiosa cuando el sistema subyacente puede realmente manejar la carga concurrente.
Si tiene tres operaciones independientes que cada una tarda 100 ms:
Sequential → ~300ms
Parallel → ~100ms
La mejora es evidente.
Pero si aumenta esa cantidad a 10,000 operaciones frente a una base de datos limitada a 100 conexiones concurrentes, el paralelismo sin restricciones puede degradar el rendimiento en lugar de mejorarlo.
Una mejor pregunta que hacerse es:
"Can I run these in parallel?"Ask:"Should I run these in parallel?"
Ese cambio de enfoque separa conocer la sintaxis de JavaScript de comprender realmente cómo se comportan los sistemas backend bajo carga.
Conclusión final
Promise.all() sigue siendo una de las herramientas más valiosas en Node.js para ejecutar tareas asíncronas independientes de forma concurrente.
No obstante, no garantiza un aumento de velocidad.
Úsalo cuando:
- Las operaciones no dependen unas de otras
- Necesitas realmente todos los resultados
- Tu sistema puede manejar la concurrencia adicional
Utiliza Promise.allSettled() cuando sea aceptable que algunas operaciones fallen sin afectar al resto.
Aplica llamadas secuenciales con await cuando las operaciones dependan de los resultados de otras.
Establece límites de concurrencia cuando estés manejando una gran cantidad de tareas al mismo tiempo.
Y recurra a colas de trabajo cuando no sea necesario completar la tarea dentro del tiempo de vida de la solicitud HTTP.
El objetivo no es simplemente reducir milisegundos en su código.
El objetivo es hacer que su sistema sea más rápido sin volverse frágil.
Lecturas relacionadas
- Nueve patrones de Promise para JavaScript asincrónico confiable en producción — Aprenda patrones prácticos de Promise: solicitudes paralelas, tiempos de espera, reintentos, límites de concurrencia y cancelación, para crear un JavaScript asincrónico resistente y apto para producción.