Choisir entre Promise.all, Promise.race et les attentes séquentielles
Apprenez quand Promise.all() accélère les APIs Node.js, pourquoi il échoue rapidement en cas de rejet, ainsi qu’un cadre décisionnel pour choisir le bon schéma asynchrone.
Exécuter des tâches asynchrones dans Node.js semble au début être une solution simple : lancer plusieurs opérations en même temps au lieu d’attendre leur exécution séquentielle, ce qui rend votre API plus rapide. En pratique cependant, considérer Promise.all() comme une habitude automatique plutôt que comme un choix délibéré peut transformer discrètement cet avantage de vitesse en problème de fiabilité. Savoir quand il est possible de paralléliser des opérations, ce qui doit se passer si l’une d’elles échoue, et quand un outil différent convient mieux est tout aussi important que de connaître la syntaxe.
1. L’approche séquentielle
Imaginez une API qui doit rassembler trois éléments :
- Les informations de l’utilisateur
- Les commandes
- Les paiements
Une première tentative naïve pourrait ressembler à ceci :
const user = await getUser(userId);
const orders = await getOrders(userId);
const payments = await getPayments(userId);
return {
user,
orders,
payments
};
Ce code fonctionne bien. Mais observez attentivement l’ordre d’exécution :
getUser()
↓
getOrders()
↓
getPayments()
Chaque étape attend que la précédente soit terminée. La récupération des commandes ne peut commencer qu’après que la récupération de l’utilisateur ait abouti, et la récupération des paiements ne peut commencer qu’après que la récupération des commandes ait abouti. Si chaque appel prend à peu près le même temps :
getUser() = 200ms
getOrders() = 200ms
getPayments() = 200ms
alors le temps total de demande s’élève à environ :
200 + 200 + 200 = 600ms
C’est du temps gaspillé si ces trois appels n’ont aucun rapport les uns avec les autres.
2. Utiliser Promise.all()
Lorsque les opérations ne dépendent pas les unes des autres, vous pouvez plutôt les lancer simultanément :
const [user, orders, payments] = await Promise.all([
getUser(userId),
getOrders(userId),
getPayments(userId)
]);
return {
user,
orders,
payments
};
Le flux d’exécution ressemble maintenant davantage à ceci :
getUser() ────────┐
│
getOrders() ────────┤
├──→ Promise.all()
getPayments() ────────┘
Plutôt que de supporter le coût de :
A + B + C
le temps total d’attente devient approximativement :
max(A, B, C)
Ainsi, si chacun des trois appels prend toujours environ 200 ms :
Sequential: ~600ms
Parallel: ~200ms
C’est un gain significatif. Mais il y a une nuance à retenir :
Promise.all() ne accélère aucune opération individuelle.
Il permet simplement aux opérations indépendantes de s’exécuter en parallèle plutôt qu’une après l’autre.
3. Un exemple d’API dans le monde réel
Imaginons un écran de tableau de bord qui doit afficher :
Profile
Orders
Wishlist
Notifications
Ces opérations pourraient correspondre à quatre requêtes distinctes vers la base de données ou des appels de service. Écrits séquentiellement, ils pourraient ressembler à ceci :
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
};
Comparons maintenant cela à la version parallèle :
const [
profile,
orders,
wishlist,
notifications
] = await Promise.all([
getUserProfile(userId),
getUserOrders(userId),
getUserWishlist(userId),
getUserNotifications(userId)
]);
return {
profile,
orders,
wishlist,
notifications
}
À condition que ces quatre appels ne dépendent pas vraiment les uns des autres, cette réécriture peut réduire de manière notable le temps d’attente total de l’API. C’est l’un des gains les plus simples à obtenir lors de l’optimisation des performances de Node.js.
Mais ce n’est pas la fin de l’histoire — il y a un piège que vous devez comprendre avant d’appliquer ce schéma partout.
4. Promise.all() échoue rapidement
C’est là que les gens se trompent. Prenons cet exemple :
const results = await Promise.all([
getUser(),
getOrders(),
getPayments()
]);
Que se passe-t-il si getPayments() génère une erreur ?
Toute la requête Promise.all() échoue, quel que soit le résultat des autres appels :
getUser() → SUCCESS
getOrders() → SUCCESS
getPayments() → ERROR
↓ Promise.all()
↓
REJECT
Vous n’obtenez pas un résultat partiel avec les deux appels réussis et un indicateur pour celui qui a échoué. Vous obtenez une exception, et aucune donnée n’est transmise :
try {
const [user, orders, payments] = await Promise.all([
getUser(userId),
getOrders(userId),
getPayments(userId)
]);
} catch (error) {
console.error(error);
}
Ce comportement tout-ou-rien est logique lorsque chaque opération du groupe est nécessaire pour que la réponse soit valide. Mais ce n’est pas toujours le choix approprié.
5. Lorsque Promise.all() n’est pas le bon choix
Supposons qu’un tableau de bord doive afficher :
- Profil
Même si le service de recommandations est temporairement indisponible, faut-il que l’ensemble du tableau de bord ne charge pas ? Presque certainement pas. Un meilleur résultat serait quelque chose comme :
Profile → Available
Notifications → Available
Recommendations → Unavailable
C’est précisément la situation pour laquelle Promise.allSettled() a été conçu :
const results = await Promise.allSettled([
getUserProfile(userId),
getRecommendations(userId),
getNotifications(userId)
]);
Au lieu de s’arrêter à la première réjection, il attend que chaque promesse soit résolue et rapporte les résultats de toutes :
[
{
status: "fulfilled",
value: profile
},
{
status: "rejected",
reason: error
},
{
status: "fulfilled",
value: notifications
}
]
À partir de là, la logique de votre application peut décider comment traiter chaque résultat individuel. La différence réside en ceci :
Promise.all()
One fails
↓
Everything rejects
par rapport à :
Promise.allSettled()
One fails
↓
You still receive every result
Aucune de ces approches n’est intrinsèquement supérieure — elles ont été conçues pour résoudre des problèmes différents, et le choix de la bonne dépend du fait qu’une seule erreur doit être considérée comme fatale pour l’ensemble du groupe.
6. Les opérations en chaîne ne doivent pas être forcées en parallèle
Il existe un autre piège à souligner.
Supposons que votre flux de travail exige que vous :
- Créiez un utilisateur
- Récupériez l’ID de cet utilisateur
- Créiez une commande liée à cet utilisateur
Ces étapes dépendent les unes des autres.
Il est physiquement impossible de créer la commande avant que l’enregistrement de l’utilisateur n’existe.
Cela signifie que du code comme celui-ci est incorrect :
await Promise.all([
createUser(),
createOrder()
]);
Étape de création de la commande nécessitant probablement l’ID de l’utilisateur en entrée.
L’approche correcte consiste à exécuter ces étapes une après l’autre :
const user = await createUser();
const order = await createOrder(user.id);
Le principe directeur ici est simple :
Les opérations qui n’ont aucun lien entre elles peuvent être exécutées en parallèle.
Les opérations qui dépendent des résultats des autres doivent s’exécuter séquentiellement.
N’optez pas pour le parallélisme juste parce que le langage vous le permet.
7. Un parallélisme illimité peut submerger votre système
Il existe un problème plus subtil qui est facile à négliger.
Examinez ce fragment de code :
await Promise.all(
users.map(user => sendEmail(user.email))
);
Avec 10 utilisateurs, cela est peu susceptible de causer des problèmes.
Avec 10 000 utilisateurs, vous lancez désormais des milliers d’opérations simultanées.
Une plus grande concurrence ne se traduit pas automatiquement par de meilleures performances.
- Des limites sur les connexions de base de données
- Des plafonds de fréquence d’appel API
- Une surcharge mémoire
- Une congestion réseau
Plutôt que d’une exécution parallèle illimitée, il vous faut souvent une concurrentisation limitée.
Une façon d’y parvenir est d’utiliser une bibliothèque de limitation de la concurrence :
import pLimit from "p-limit";
const limit = pLimit(5);const results = await Promise.all(
users.map(user =>
limit(() => sendEmail(user.email))
)
);
Avec cette configuration, pas plus de cinq opérations s’exécutent en même temps.
Visuellement, la différence se présente comme suit :
1000 tasks
↓Concurrency limit = 5 ↓5 tasks
5 tasks
5 tasks
5 tasks
...
Cela est plus lent que de lancer les 1 000 tâches d’un seul coup.
Mais cela vous offre un contrôle bien plus grand.
Dans des conditions réelles, une exécution contrôlée fonctionne souvent mieux dans l’ensemble, car elle évite de saturer les ressources dont dépendent vos opérations.
8. Promise.race() résout un problème différent
Il existe une autre méthode qui est souvent confondue avec Promise.all():
Promise.race()
Promise.race() se résout — en se résolvant ou en étant rejeté — dès que la première promesse du groupe se résout.
Par exemple :
const result = await Promise.race([
serverA(),
serverB()
]);
Visuellement :
Server A ───────────────→ 500ms
Server B ───────→ 200ms ↓
Promise.race()
↓
Result
Cette approche a des utilisations légitimes, comme la concurrence entre des requêtes redondantes ou l’implémentation d’un comportement de délai.
Cependant, gardez à l’esprit ceci :
Promise.race() n’arrête pas automatiquement les opérations qui perdent la concurrence.
Si vous devez annuler les opérations perdantes, vous devez le faire vous-même, généralement à l’aide d’éléments tels que AbortController.
9. N’oubliez pas le comportement de tentative
Supposons que vous effectuiez une appel à un service externe :
const result = await paymentService();
L’appel échoue en raison d’un problème temporaire sur le réseau.
Si vous enveloppez tout dans une grande Promise.all() et que vous réessayez aveuglément en cas d’échec, vous pouvez introduire un nouveau problème.
Vous risquez de déclencher une tempête de réessais.
Au lieu de réessayer immédiatement, prenez en compte :
- Quelles opérations sont vraiment sûres à réessayer ?
- Combien d’essais de réessai sont autorisés ?
- Pendant combien de temps devez-vous attendre entre chaque essai ?
- L’opération est-elle idempotente ?
- Que se passe-t-il si le service externe est déjà sollicité ?
Un schéma courant pour les erreurs temporaires est le recul exponentiel.
Conceptuellement :
Attempt 1 → fail
↓
wait
↓
Attempt 2 → fail
↓
wait longer
↓
Attempt 3 → success
La vitesse a peu d’importance si elle nuit à la fiabilité.
10. Appliquer le même raisonnement aux requêtes de base de données
Il est tentant de penser que, puisque JavaScript prend en charge les promesses concurrentes, les requêtes de base de données devraient toujours être exécutées en parallèle.
Cela n’est pas toujours vrai.
Prenons cet exemple :
await Promise.all([
database.users.findMany(),
database.orders.findMany(),
database.products.findMany(),
database.payments.findMany(),
database.notifications.findMany()
]);
Cela déclenche approximativement cinq opérations de base de données en même temps.
Cela peut être tout à fait acceptable.
Ou bien cela pourrait pousser votre base de données au-delà de ses limites pendant les périodes de forte activité.
Les facteurs à prendre en compte incluent :
- Le degré de complexité de chaque requête
- La taille du pool de connexions à la base de données
- Le nombre d’instances API en cours d’exécution
- Le volume total du trafic
- L’existence d’index adéquats
- Le temps nécessaire à l’exécution de chaque requête
- Les ressources CPU et mémoire disponibles sur le serveur de base de données
L’optimisation des performances ne peut pas se faire dans le vide.
Votre API n’est qu’une partie d’un système bien plus vaste.
11. Un cadre pour décider quand utiliser Promise.all()
Au préalable de l’utiliser, il est utile de se poser trois questions.
Question 1 : Les opérations sont-elles indépendantes ?
Si c’est le cas, il peut être avantageux de les exécuter en parallèle.
Si ce n’est pas le cas, respectez l’ordre dans lequel elles dépendent.
Question 2 : Que se passe-t-il si une opération échoue ?
Si un seul échec doit invalider l’ensemble du lot :
Promise.all()
est probablement l’outil adapté.
Si vous préférez collecter les résultats des opérations qui ont réussi :
Promise.allSettled()
est généralement plus approprié.
Question 3 : Combien d’opérations sont lancées en même temps ?
Trois appels simultanés ?
C’est généralement gérable.
Dix mille ?
C’est un défi complètement différent.
Vous devrez peut-être introduire :
- Des limites de concurrence
- Le regroupement en lots
- La mise en file d’attente
- La pagination
- La limitation de vitesse
12. Un résumé rapide pour choisir une approche
| Situation | Méthode recommandée |
|---|---|
| Opérations indépendantes, toutes doivent réussir | Promise.all() |
| Opérations indépendantes, un succès partiel est acceptable | Promise.allSettled() |
| Les opérations dépendent les unes des autres | await séquentiel |
| Il suffit que la première à se terminer soit utilisée | Promise.race() |
| De nombreuses tâches nécessitant une concurrence limitée | p-limit ou regroupement en lots |
| Tâches en arrière-plan lentes et non urgentes | Fil d’attente ou processus travailleur |
| Appels externes sujets à des échecs temporaires | Logique de tentative avec retards progressifs |
Il ne s’agit pas de mémoriser ce tableau.
Il s’agit plutôt de comprendre la logique derrière chaque choix.
13. La leçon principale
Lorsque l’on rencontre pour la première fois Promise.all(), il est naturel de penser :
« Exécuter des tâches en parallèle est toujours plus rapide. »
Cette supposition n’est pas fondée.
Une façon plus précise de voir les choses est :
L’exécution en parallèle ne s’avère utile que lorsque le système sous-jacent peut effectivement gérer la charge concurrente.
Si vous avez trois opérations indépendantes qui prennent chacune 100 ms :
Sequential → ~300ms
Parallel → ~100ms
La amélioration est évidente.
Mais si l’on passe à 10 000 opérations sur une base de données limitée à 100 connexions simultanées, un parallélisme non contrôlé peut au contraire dégrader les performances.
Une meilleure question à se poser est :
"Can I run these in parallel?"Ask:"Should I run these in parallel?"
Cette évolution de la façon de penser distingue la connaissance de la syntaxe de JavaScript de la compréhension réelle du comportement des systèmes backend sous charge.
Conclusion finale
Promise.all() reste l’un des outils les plus utiles de Node.js pour exécuter en parallèle des tâches asynchrones indépendantes.
Cela dit, il ne garantit pas forcément une amélioration de la vitesse.
L’utilisez lorsque :
- Les opérations ne dépendent pas les unes des autres
- Vous avez réellement besoin de tous les résultats
- Votre système peut gérer cette concurrence supplémentaire
Préférez Promise.allSettled() lorsque l’échec de certaines opérations n’affecte pas le reste du processus.
Utilisez des appels séquentiels avec await lorsque les opérations dépendent des résultats des autres.
Mettez en place des limites de concurrence lorsque vous traitez un grand nombre de tâches en même temps.
Et recourir à des files d’attente lorsque le travail n’a pas besoin d’être terminé au cours de la durée de vie de la requête HTTP.
L’objectif n’est pas seulement d’économiser quelques millisecondes dans votre code.
L’objectif est de rendre votre système plus rapide sans le rendre fragile.
Lectures complémentaires
- Neuf patterns Promise pour un JavaScript asynchrone fiable en production — Découvrez des patterns Promise pratiques—demandes parallèles, délais d’expiration, tentatives de réessai, limites de concurrence et annulation—pour créer du JavaScript asynchrone résilient, adapté à la production.