Accueil / Articles / Neuf modèles de promesses pour un JavaScript asynchrone fiable en production

Neuf modèles de promesses pour un JavaScript asynchrone fiable en production

Apprenez les modèles pratiques de Promise : requêtes parallèles, délais d’expiration, tentatives répétées, limites de concurrence et annulation, afin de créer du JavaScript asynchrone fiable adapté à un environnement de production.

1998 mots

Vous vous tournez constamment vers les Promises, mais quelques modèles moins connus peuvent transformer du code asynchrone embrouillé en quelque chose de prévisible et facile à comprendre.

La plupart des gens découvrent les Promises grâce à un exemple comme celui-ci :

fetch("/api/users")
.then((res) => res.json())
.then((users) => console.log(users));

Et pour des scripts simples, c’est vraiment tout ce dont vous avez besoin.

Les problèmes commencent dès que l’on passe à des applications de niveau production.

Soudain, vous avez besoin que plusieurs requêtes s’exécutent en même temps plutôt qu’une après l’autre.

Vous avez besoin d’un moyen d’annuler des tâches qui ne sont plus pertinentes.

Vous avez besoin de tentatives répétées en cas d’échec d’une requête.

Vous devez continuer même lorsque seule une partie d’une opération réussit.

Vous devez empêcher qu’une même appel API ne soit exécuté deux fois par accident.

Et parfois, vous devez limiter le nombre d’opérations asynchrones qui s’exécutent simultanément afin que votre backend ne soit pas submergé d’un coup.

C’est à ce stade que les Promises cessent d’être un sujet destiné aux débutants pour devenir un véritable outil de conception.

Voici neuf modèles utiles à avoir dans votre boîte à outils lors de la rédaction de JavaScript moderne.

1. Exécuter des requêtes indépendantes en parallèle

L’un des gains les plus simples dans le code asynchrone est aussi l’un des plus faciles à négliger.

Disons que votre page a besoin de trois éléments : des données utilisateur, des notifications et des statistiques.

Une première tentative courante ressemble à ceci :

const users = await getUsers();
const notifications = await getNotifications();
const analytics = await getAnalytics();
Each request waits for the previous one.

Chaque await bloque jusqu’à ce que l’appel précédent soit résolu, de sorte que les appels s’enchaînent séquentiellement.

En supposant que chaque appel dure environ 500 ms, cette chaîne séquentielle pourrait entraîner un temps d’attente total d’environ 1,5 seconde.

Puisque aucun de ces appels ne dépend réellement des autres, il n’y a aucune raison d’attendre.

C’est précisément à cela que sert Promise.all() :

const [users, notifications, analytics] = await Promise.all([
getUsers(),
getNotifications(),
getAnalytics(),
]);

Toutes les trois requêtes sont maintenant envoyées en même temps au lieu de se succéder.

Pour les tableaux de bord ou tout écran chargant plusieurs sources de données indépendantes, cela peut réduire considérablement le temps de chargement.

L’aspect important à connaître

Promise.all() échoue rapidement : dès que l’une des promesses échoue, tout le groupe échoue avec elle.

C’est acceptable lorsque chaque requête est obligatoire, mais si certaines données sont optionnelles, vous aurez besoin d’une approche plus flexible.

2. Utilisez Promise.allSettled() lorsque des échecs partiels sont acceptables

Imaginez un tableau de bord administratif qui affiche :

  • Le chiffre d’affaires
  • Les utilisateurs
  • Les notifications
  • L’état du système

Si le service de notifications est indisponible, faut-il que tout l’écran devienne vide ?

Généralement non — la perte d’un panneau ne devrait pas entraîner la disparition du reste de la page.

Promise.allSettled() résout précisément ce problème.

const results = await Promise.allSettled([
getRevenue(),
getUsers(),
getNotifications(),
getSystemHealth(),
]);
results.forEach((result) => {
if (result.status === "fulfilled") {
console.log("Success:", result.value);
} else {
console.error("Failed:", result.reason);
}
});

Une seule appelation échouée ne supprime plus les résultats réussis qui se trouvent à côté.

Cette approche s’avère utile sur les tableaux de bord, les écrans d’analyse et les outils de surveillance, où afficher des données partielles vaut mieux que ne rien afficher du tout.

3. Ajouter un délai d’attente à une Promise

Tôt ou tard, vous rencontrerez ce scénario : que se passe-t-il si une requête ne revient jamais ?

Sans mesure de protection, votre interface peut rester bloquée indéfiniment.

Vous pouvez créer un petit wrapper réutilisable qui impose un délai d’attente :

function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Operation timed out"));
}, ms);
});
return Promise.race([promise, timeout]);
}

Puis l’utiliser chaque fois que vous effectuez une requête :

try {
const data = await withTimeout(fetch("/api/data"), 5000);
console.log(data);
} catch (error) {
console.error(error);
}

Si l’appel ne se termine pas en cinq secondes, la Promise encapsulée est rejetée automatiquement.

C’est une expérience bien meilleure que de laisser les utilisateurs fixer un indicateur en rotation qui ne se résout jamais.

4. Réessayer les opérations échouées

Les réseaux interrompent parfois les connexions.

Les serveurs connaissent des pannes occasionnelles.

Les API de tiers se comportent parfois de manière anormale.

Rien de tout cela ne signifie nécessairement que l’utilisateur doit immédiatement voir un message d’erreur.

Pour les échecs susceptibles d’être temporaires, un petit outil de réessai est très utile.

async function retry(fn, attempts = 3) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
}
}
throw lastError;
}

On l’appellerait ainsi :

const data = await retry(
() => fetch("/api/data"),
3
);

L’opération dispose désormais de quelques chances supplémentaires avant d’être considérée comme un véritable échec.

Mais ne réessayez pas tout aveuglément.

Un statut 500 indique souvent un problème temporaire du serveur.

En revanche, un 401 Unauthorized ne sera pas résolu en envoyant la même requête trois fois de plus.

Une logique de réessai solide permet de distinguer les échecs qui méritent d’être réessayés de ceux qui ne le font pas.

5. Ajouter des délais entre les réessais

Tenter une nouvelle tentative immédiatement en cas d’échec d’une requête n’est pas toujours la meilleure solution.

Pensez à un serveur qui a déjà du mal à gérer la charge.

Si des milliers de clients tentent toutes des nouvelles tentatives en même temps, vous ajoutez encore plus de pression sur un système déjà sollicité.

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

Vous pouvez l’intégrer directement dans votre boucle de tentatives :

async function retry(fn, attempts = 3) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (i < attempts - 1) {
await delay(1000);
}}}
throw lastError;
}

Les systèmes en production vont souvent plus loin et utilisent un retard exponentiel, espacant les tentatives de cette manière :

1 second
2 seconds
4 seconds
8 seconds

En espacant les tentatives de cette façon, on donne au serveur en difficulté le temps de se remettre plutôt que de déclencher une tempête de tentatives.

6. Gestion de la concurrence

Promise.all() peut introduire discrètement un problème.

Disons que vous devez traiter 1 000 requêtes API.

L’approche naïve semble suffisamment inoffensive :

await Promise.all(
items.map((item) => processItem(item))
);
But now you’re potentially starting 1,000 operations at once.

Mais cela signifie que vous pourriez lancer les 1 000 opérations au même instant précis.

C’est rarement ce que vous souhaitez réellement.

Généralement, vous préférez limiter le nombre d’opérations exécutées en parallèle.

Pensez à quelque chose comme ceci :

1000 tasks
↓
5 at a time
↓
5 at a time
↓
5 at a time

Créer un limiteur de concurrence de base n’est pas difficile :

async function runWithLimit(items, limit, fn) {
const results = [];
let index = 0;
async function worker() {
while (index < items.length) {
const currentIndex = index++;
results[currentIndex] = await fn(items[currentIndex]);
}
}
const workers = Array.from(
{ length: limit },
() => worker()
);
await Promise.all(workers);
return results;
}

Vous l’utiliserez de cette manière :

const results = await runWithLimit(
items,
5,
(item) => processItem(item)
);

Désormais, c’est vous qui décidez précisément du nombre de tâches exécutées en même temps, au lieu de laisser le système d’exécution le faire à votre place.

Cette technique est extrêmement utile lorsque vous travaillez avec des **grandes bases de données, des API externes, du traitement de fichiers ou des files d’attente de tâches en arrière-plan**.

7. Prévenir les demandes dupliquées

Voici un problème qui apparaît plus souvent que ce à quoi on pourrait s’attendre.

Un utilisateur charge une page de tableau de bord.

Trois composants distincts ont tous besoin des mêmes données de profil utilisateur.

Au lieu d’envoyer trois appels séparés :

Component A → /api/user
Component B → /api/user
Component C → /api/user

vous pouvez leur faire partager une seule Promise.

const pendingRequests = new Map();
function getUser(id) {
if (pendingRequests.has(id)) {
return pendingRequests.get(id);
}
const request = fetch(`/api/users/${id}`)
.then((res) => res.json())
.finally(() => {
pendingRequests.delete(id);
});
pendingRequests.set(id, request);
return request;
}

Avec cette configuration, si trois composants demandent le même utilisateur à peu près en même temps, ils s’attachent tous à une seule requête en cours plutôt que de déclencher trois requêtes.

En d’autres termes :

3 requêtes → 1 requête

Cette technique est souvent appelée déduplication des requêtes.

Dans les bases de code plus importantes, des outils comme TanStack Query mettent déjà en œuvre une logique de mise en cache et de déduplication de ce type pour vous.

8. Utilisez Promise.any() lorsque vous n’avez besoin que d’un seul résultat réussi

Parfois, la même donnée est disponible à partir de plusieurs endroits différents.

Par exemple :

API Server A
API Server B
API Server C

Si votre application a simplement besoin d’une seule réponse réussie parmi elles, Promise.any() convient parfaitement.

const result = await Promise.any([
fetchFromServerA(),
fetchFromServerB(),
fetchFromServerC(),
]);

La Promise qui s’accomplit en premier remporte la course.

Notez que ce comportement diffère de celui de Promise.race().

Promise.race() résout ou rejette la Promise en fonction de celle qui s’arrête en premier, que ce soit avec succès ou échec.

Promise.any(), quant à lui, attend spécifiquement la première Promise qui s’accomplit avec succès, en ignorant les rejets à moins que tous ne échouent.

Cette distinction peut sembler mineure, mais elle peut complètement changer la manière dont vous gérez les erreurs.

9. Annuler le travail dont vous n’avez plus besoin

Ce pattern est l’un des plus satisfaisants à appliquer.

Pensez à une zone de recherche en temps réel :

user types: react
user types: react dashboard
user types: react dashboard ui

Vous ne voulez probablement pas que la demande générée par chaque frappe antérieure continue de s’exécuter en arrière-plan une fois qu’elle est obsolète.

C’est précisément la situation pour laquelle AbortController a été conçu.

const controller = new AbortController();
fetch("/api/search?q=react", {
signal: controller.signal,
});

Lorsque la requête n’est plus nécessaire, il suffit d’appeler :

controller.abort();
The request can then be cancelled.

Cette approche apparaît constamment dans des scénarios tels que :

  • Les suggestions de recherche
  • Les champs de complétion automatique
  • Les transitions de route ou de page
  • Le démontage ou le nettoyage des composants
  • Les requêtes remplacées par de nouvelles

La véritable compétence ici n’est pas seulement d’appeler abort().

C’est de reconnaître le moment précis où le travail effectué auparavant cesse d’être utile.

La leçon plus importante

Les Promises ne semblent pas difficiles parce que .then() est une API complexe.

Elles semblent difficiles parce que les applications du monde réel impliquent un comportement asynchrone enchevêtré et multicouche.

Il vous faut constamment résoudre des problèmes tels que :

Ces opérations doivent-elles s’exécuter en même temps ?

Quel est le plan en cas d’échec de l’une d’elles ?

Quelle est la durée excessive d’attente ?

Vaut-il la peine de réessayer dans ce cas ?

Combien d’opérations doivent s’exécuter en parallèle ?

Puis-je éviter de dupliquer un travail déjà en cours ?

Cette demande reste-t-elle pertinente ?

Dès que vous vous posez ce type de questions, les Promises cessent d’être une simple fonctionnalité linguistique.

Elles deviennent un véritable Outil architectural.

Mes 9 modèles de Promises en un coup d’œil

Promise.all() est idéal pour exécuter des tâches indépendantes en même temps. Promise.allSettled() gère les échecs partiels de manière appropriée. Promise.race() convient aux délais d’expiration ou pour réagir au premier élément à se résoudre. La logique de tentative permet de surmonter les échecs temporaires. Les stratégies de retard et d’ajustement évitent que les tentatives ne deviennent trop fréquentes. La limitation de la concurrence permet de garder un contrôle sur les charges de travail importantes. La déduplication des requêtes empêche les appels API redondants. Promise.any() vous permet d’obtenir le premier résultat réussi parmi plusieurs. AbortController vous permet d’annuler des tâches qui ne sont plus nécessaires.

Vous n’avez pas besoin de tous ces modèles dans chaque projet.

En fait, les utiliser tous serait contre-productif.

La véritable compétence réside dans l’identification du problème à résoudre avant de choisir un modèle particulier.

Dernière pensée

Le meilleur code asynchrone n’est pas celui rempli des astuces les plus ingénieuses avec Promise.

C’est celui où la gestion des erreurs, le timing, la concurrence et l’annulation ont été soigneusement planifiés avant de se transformer en problèmes en production.

Commencez par la version la plus simple.

Faites attention à ce qui cause réellement des difficultés.

N’intégrez ensuite les patterns spécifiques que pour résoudre ces problèmes.

Car parfois, la meilleure approche en JavaScript consiste à reconnaître quand un pattern n’est absolument pas nécessaire.

Lectures complémentaires

  • Choisir entre Promise.all, Promise.race et les attentes séquentielles — Découvrez quand Promise.all() accélère les API de Node.js, pourquoi il échoue rapidement en cas de rejet, ainsi qu’un cadre décisionnel pour choisir le bon modèle asynchrone.
  • Async/Await vs Promises : Qu’est-ce qui diffère réellement en arrière-plan — Explique les véritables différences d’exécution, de mémoire et d’empreinte d’stack entre async/await et Promises, ainsi que les cas où il convient encore d’utiliser les API natives de Promise.