Await ne voit qu’une seule promesse : concevoir des API Async/Await qui assument pleinement leur fonctionnement
Ce que JavaScript async/await observe réellement, pourquoi les tâches déconnectées, les callbacks, les flux et Promise.all lui échappent, et comment concevoir des Promises qui indiquent réellement l’achèvement.
Sources: 90% of Developers Don’t Understand What JavaScript `await` Actually Waits For
Il est facile de comprendre await comme « attendre que cette fonction soit terminée », mais le moteur d’exécution fait une promesse bien plus restreinte : il évalue une expression, traite le résultat comme une valeur de type Promise, et suspend la fonction async en cours jusqu’à ce que cette seule valeur soit déterminée. Tout le reste dépend de la manière dont cette valeur a été produite. Cet écart explique pourquoi du code contenant des mots-clés await placés avec soin peut encore renvoyer des résultats prématurément, fonctionner plus lentement que nécessaire, ignorer des erreurs ou agir sur des données obsolètes. Après avoir étudié les cas ci-dessous, vous serez en mesure de regarder n’importe quel await et de vous poser la seule question importante : ce Promise représente-t-il réellement le travail qui m’intéresse ?
Le problème : du code en attente qui semble terminé mais ne l’est pas
Les bugs dans cette zone suivent un schéma commun. Un développeur attend une fonction et suppose que tout ce que cette fonction a initié est terminé. Il attend une méthode d’array et suppose que chaque appel asynchrone a été exécuté. Il attend la configuration d’un chronomètre, l’abonnement à un événement ou l’écriture dans un flux et croit que l’opération sous-jacente est achevée. Le code semble fonctionner correctement parce que await se trouve bien là.
Mais await n’a aucune notion de « la tâche », « la requête » ou « tout ce que cette fonction a démarré ». Si l’expression à sa droite ne couvre pas l’ensemble du cycle de vie du travail, rien dans ce mot-clé ne peut s’étendre pour le couvrir. Garder cela à l’esprit change la manière dont on conçoit les API, les boucles, les tâches en arrière-plan, le code piloté par des événements et la gestion des erreurs.
L’appel a lieu avant l’attente
Prenons la ligne la plus ordinaire en JavaScript asynchrone.
const user = await loadUser();
Le moteur de exécution ne voit pas d’abord await puis demande poliment à loadUser de s’exécuter de manière asynchrone. Il évalue loadUser() exactement comme n’importe quelle autre appel de fonction. Cet appel produit une valeur, qui peut être une Promise native, un objet thenable personnalisé, un objet ordinaire, une chaîne de caractères, undefined ou tout autre type. Ce n’est qu’après cela que await peut traiter le résultat.
La conséquence est que l’opération réelle commence généralement pendant l’appel, avant même que toute attente ne commence. Si vous appelez deux fonctions asynchrones et conservez leurs Promises avant d’attendre l’une ou l’autre, les deux opérations se déroulent presque simultanément :
const userPromise = loadUser();
const settingsPromise = loadSettings();
const user = await userPromise;
const settings = await settingsPromise;
Ici, loadUser() commence son travail et renvoie une Promise, puis loadSettings() commence le sien. Les deux lignes await se contentent de récupérer les résultats dès qu’ils sont disponibles.
Comparez cela à la version que la plupart des gens écrivent par défaut :
const user = await loadUser();
const settings = await loadSettings();
Désormais, loadSettings() n’est même pas appelé avant que la première Promise ne soit résolue et que la fonction ne reprenne son exécution. Les deux requêtes s’exécutent l’une après l’autre.
Remarquez que await lui-même ne prend aucune décision concernant le parallélisme. C’est la position des appels de fonction qui le fait. Une façon concise de s’en souvenir : appeler une fonction asynchrone démarre l’opération ; await décide du moment où la fonction en cours reprend. Une précaution concernant le premier schéma : si settingsPromise est rejeté tandis que vous attendez encore userPromise, ce rejet reste temporairement non géré, ce qui peut être signalé comme une erreur par certains environnements d’exécution. Promise.all évite ce problème en surveillant les deux Promises en même temps. Pour une comparaison plus approfondie de ces approches, consultez choosing between Promise.all, Promise.race and sequential awaits.
Résolution d’une seule Promise, et non de tous les effets secondaires
Considérez une fonction de création de compte qui enregistre un dossier, envoie un e-mail de bienvenue sans l’attendre ni en récupérer la réponse, puis renvoie le compte.
async function createAccount(input) {
const account = await accountRepository.save(input);
sendWelcomeEmail(account.email);
return account;
}
Un appelant écrit alors la phrase évidente :
const account = await createAccount(input);
Cette fonction est exécutée à nouveau lorsque la Promise retournée par createAccount est satisfaite, ce qui se produit après que l’enregistrement ait été terminé et que la fonction ait renvoyé une valeur. Elle ne vous donne aucune information concernant l’e-mail. sendWelcomeEmail a créé une opération asynchrone distincte dont la Promise n’a jamais été liée à celle retournée par createAccount ; par conséquent, du point de vue de cette fonction, ce travail n’existe pas.
C’est là l’essence du malentendu. await ne suit que la chaîne des Promises accessibles depuis la valeur qui lui a été fournie. Il ne parcourt pas tout ce que la fonction a démarré, et il ne patiente certainement pas que le processus devienne inactif.
Les conséquences pratiques sont triples :
- Si la Promise relative à l’e-mail échoue, le bloc
try/catchde la fonction ne la détectera jamais. - Si le processus s’arrête, l’e-mail pourrait tout simplement ne jamais être envoyé.
Tout cela ne signifie pas pour autant que tous les effets secondaires doivent s’achever au sein de la requête. De nombreuses tâches doivent légitimement être exécutées en arrière-plan. Le véritable défaut réside dans l’absence de responsabilités claires. Les tâches importantes doivent être renvoyées et attendues, marquées explicitement comme optionnelles ou gérées par une file d’attente ou un système de tâches persistant. Créer une Promise sans en assurer le suivi n’est pas un traitement en arrière-plan ; c’est simplement espérer.
Une fonction asynchrone peut se terminer tandis que ses sous-tâches sont encore en cours d’exécution
Une hypothèse similaire est que le mot-clé async amène JavaScript à suivre chaque opération asynchrone lancée à l’intérieur de la fonction. Ce n’est pas le cas. Une fonction async génère exactement une Promise, et cette Promise n’est liée qu’à ce que la fonction renvoie explicitement ou attend au cours de son propre flux de contrôle. Tout le reste peut continuer à s’exécuter après que cette Promise ait été résolue.
L’illustration classique utilise forEach :
async function processUsers(users) {
users.forEach(async (user) => {
await updateUser(user);
});
}
Chaque appel de la fonction de rappel asynchrone renvoie une Promise, mais forEach jette ces valeurs de retour. Il ne les collecte pas, ne les attend pas et ne les affiche pas ; il renvoie undefined. Ainsi, processUsers atteint sa parenthèse de fermeture presque instantanément, et comme une fonction async qui se termine ainsi se résout avec undefined, l’appelant continue bien avant que toute mise à jour ne soit terminée :
await processUsers(users);
console.log("All users processed");
L’affirmation est simplement fausse. L’appelant a attendu que processUsers termine de planifier les callbacks, et non le traitement effectif de ces derniers.
Pour y remédier, les Promises enfants doivent être intégrées à l’opération parente. Selon la situation, cela signifie utiliser une boucle for...of avec await pour assurer une séquence stricte, Promise.all(users.map(updateUser)) pour un parallélisme total, ou un limiteur de concurrence lorsque la collection est suffisamment volumineuse pour surcharger une base de données ou une API. La règle générale s’applique bien au-delà de forEach : une Promise parente n’attend que le travail des enfants qui font partie de sa chaîne. Des outils tels que les règles no-floating-promises et no-misused-promises de typescript-eslint détectent automatiquement de nombreuses de ces erreurs.
Les APIs de callback sont invisibles pour await
Une grande partie de l’écosystème JavaScript existe avant Promises et indique l’achèvement via des callbacks. Cela conduit à du code comme celui-ci :
await setTimeout(() => {
console.log("Timer completed");
}, 1000);
Il ne s’arrête pas un seul instant. setTimeout enregistre la fonction de rappel et renvoie immédiatement une référence de temporiseur (un nombre dans les navigateurs, un objet Timeout dans Node.js). Cette référence n’est pas une Promise indiquant quand le temporiseur se déclenchera. await reçoit une valeur ordinaire, la traite comme déjà disponible et reprend l’exécution de la fonction lors du prochain microtask, bien avant que la fonction de rappel ne s’exécute.
Le même problème se pose avec les écouteurs d’événements, les anciennes API de système de fichiers, les pilotes de base de données, les flux et les SDK personnalisés. Si une fonction renvoie une abonnement, une référence de requête, une valeur booléenne ou un émetteur d’événements, attendre cette valeur renvoyée ne transforme pas la fonction de rappel en une complétion de Promise.
Pour attendre un comportement basé sur des fonctions de rappel, il vous faut une Promise qui se résout ou se rejette précisément au moment où la fonction de rappel indique le résultat :
function delay(milliseconds) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
await delay(1000);
Désormais, la valeur attendue correspond réellement à l’événement qui nous intéresse : la Promise reste en attente jusqu’à ce que le timer appelle resolve. Les plateformes modernes fournissent souvent déjà de tels enveloppements ; Node.js, par exemple, propose une version basée sur des Promises pour setTimeout dans node:timers/promises ainsi que util.promisify pour les fonctions de type callback.
Cela soulève la question de conception qui se cache derrière chaque await : la valeur retournée représente-t-elle l’achèvement de l’opération, ou simplement son enregistrement, son démarrage, ou un identifiant désignant une tâche encore en cours ? S’il s’agit de l’un de ces derniers cas, attendre cette valeur ne signifie pas attendre le résultat que l’on imagine.
Thenables, valeurs simples et la frontière des microtâches
await ne requiert pas une instance réelle de Promise. Si la valeur est un objet thenable, c’est-à-dire un objet disposant d’une méthode .then appelable, await utilise le mécanisme d’exécution de cet objet. C’est ce qui permet aux bibliothèques compatibles avec Promise et aux objets asynchrones personnalisés de s’intégrer directement dans les fonctions async, et cela signifie qu’un objet peut se comporter de manière asynchrone sans jamais passer par new Promise.
Pour les valeurs qui ne sont pas thenables, await insère néanmoins une étape asynchrone :
async function example() {
console.log("before");
await 42;
console.log("after");
}
console.log("start");
example();
console.log("end");
Ce code affiche start, before, end et seulement ensuite after, bien que 42 soit disponible immédiatement. La fonction produit une valeur au niveau de await ; le code synchrone suivant la fonction est exécuté, et le reste de example s’exécute plus tard en tant que microtâche.
Ainsi, attendre une valeur simple n’est pas synchrone. Cela peut modifier l’ordre d’exécution de votre code par rapport aux instructions synchrones environnantes et aux autres microtâches en file d’attente. Dans les chemins d’exécution sensibles à l’ordre ou les chemins fréquemment utilisés, un await inutile crée une limite de planification. Le retard est généralement négligeable du point de vue des performances, mais le principe reste le même : le code après await ne continue jamais sur la même pile d’appels ininterrompue.
Le rejet fait partie du processus de résolution
Les gens disent souvent que await attend que une Promise soit résolue. Il est plus exact de dire qu’il attend que la Promise soit finalisée, et la finalisation inclut le rejet. Lorsque la Promise attendue est rejetée, await lance la raison du rejet à l’intérieur de la fonction async en cours, ce qui permet aux blocs try/catch classiques de fonctionner en cas d’échec asynchrone :
try {
const order = await loadOrder(orderId);
return order;
} catch (error) {
logger.error(error);
throw error;
}
Ce mécanisme de protection ne s’applique que si la Promise qui échoue est réellement attendue à l’intérieur du bloc try. Une Promise créée et laissée en suspens en échappe complètement :
try {
loadOrder(orderId);
} catch (error) {
console.error("This does not catch the Promise rejection");
}
Ici, l’appel lui-même a réussi : il a renvoyé une Promise sans lancer d’erreur. Le rejet intervient plus tard, via cette Promise, après que le bloc try a depuis longtemps quitté son exécution.
Cela est particulièrement dangereux lorsque await est utilisé délibérément pour rendre quelque chose « non bloquant », alors que le développeur pense encore que les blocs try/catch environnants le protègent. Ce n’est pas le cas. Chaque opération asynchrone nécessite un responsable clair en cas d’échec : un appelant qui l’attend ou la renvoie, un gestionnaire .catch explicite, ou un système fiable qui assume la responsabilité. Une Promise sans responsable n’implique pas une architecture plus rapide ; elle représente plutôt une voie d’erreur isolée du code capable de comprendre ce qui s’est passé. L’article sur ce que async/await garantit réellement explore cette même notion de responsabilité du point de vue du traitement des erreurs.
Promise.all coordonne les résultats, pas les issues
Promise.all est généralement décrit comme « attendre plusieurs Promises en même temps ». C’est une bonne approximation de base, mais les détails sont importants. Il retourne une nouvelle Promise qui s’accomplit dès que chacune des Promises d’entrée s’accomplit, et qui échoue dès que l’une d’elles échoue.
const results = await Promise.all(tasks);
Même ici, await vise un seul objectif : la Promise globale créée par Promise.all.
Lorsqu’une tâche échoue prématurément, cette Promise globale échoue également et votre fonction suit le chemin d’erreur correspondant. Les tâches restantes ne sont pas annulées ; elles continuent de s’exécuter et peuvent encore provoquer des effets secondaires après que l’appelant ait déjà déclaré le lot comme échoué. Imaginez un lot qui déclenche plusieurs mises à jour externes : l’une échoue immédiatement, l’appelant signale « lot échoué », tandis que les autres mises à jour réussissent discrètement, laissant le système partiellement modifié.
Promise.all vous permet de coordonner les résultats, mais pas de gérer des transactions ou des annulations. Choisissez l’outil qui correspond aux besoins sémantiques que vous avez :
- Si un succès partiel est acceptable, collectez tous les résultats (par exemple avec
Promise.allSettled) et examinez chacun d’eux. - Si vous avez besoin d’un comportement tout ou rien, les opérations sous-jacentes nécessitent un mécanisme de réversion ou de compensation réel.
- Si le travail restant doit s’arrêter après la première erreur, chaque opération doit permettre l’annulation, généralement via un
AbortSignalpartagé.
La Promise combinée ne peut pas annuler un travail qui a déjà commencé. Son résolution ne signifie que ce que sa construction lui permet de signifier.
Flux et ressources à long terme
Les flux constituent une autre source fréquente de confusion. La méthode write d’un flux peut retourner un booléen indiquant si le buffer interne peut encore recevoir des données. Une appelation à .pipe() renvoie le flux de destination. Un analyseur basé sur des événements peut retourner dès que ses écouteurs ont été attachés. Attendre l’un de ces valeurs ne signifie pas attendre que le flux termine, vider son buffer, se fermer ou écrire chaque octet.
La fin est généralement signalée par des événements tels que finish, end, close ou error ; pour pouvoir l’attendre de manière fiable, ces événements doivent être transformés en Promise. De nombreuses bibliothèques, ainsi que Node.js lui-même via pipeline dans node:stream/promises, fournissent des outils qui génèrent un seul Promise dont le règlement couvre l’ensemble du pipeline, y compris ses erreurs.
Ce principe s’applique à tout ressource à longue durée de vie :
- Démarrer un téléchargement ne signifie pas l’avoir terminé.
- Ouvrir une connexion ne signifie pas avoir finalisé une requête.
- Planifier une tâche ne signifie pas la lancer.
- Insérer un message dans une file d’attente ne signifie pas le traiter.
La valeur renvoyée par une API définit ce que await peut observer. Avant de faire confiance à un await, déterminez quel point de fin de cycle de vie son Promise marque réellement.
La finalisation n’est pas une question de pertinence
Imaginez une boîte de recherche où un utilisateur tape « JavaScript » puis la change rapidement en « TypeScript ». Deux requêtes sont en cours d’exécution. La requête TypeScript renvoie d’abord et l’écran affiche les bons résultats ; ensuite, la réponse plus lente de JavaScript arrive et les remplace.
Chaque fonction dans ce flux a utilisé await correctement. Le bug existe parce que l’attente et la pertinence sont des considérations distinctes. await reprend son exécution lorsque une Promise est résolue ; il ne vérifie pas si quelqu’un souhaite encore le résultat, si une requête plus récente l’a remplacé, ou si le composant affiche désormais un enregistrement différent. Un code qui semble séquentiel ne fait queordonner les étapes au sein d’une seule invocation, et plusieurs invocations de la même fonction peuvent toujours se chevaucher librement.
Pour résoudre ce problème, il est nécessaire d’avoir une notion explicite de l’opération en cours. Vous pouvez annuler les demandes obsolètes, comparer chaque réponse à l’identifiant de la dernière demande, utiliser des vérifications de version, ou compter sur une couche de récupération de données qui associe les réponses aux paramètres qui les ont générées. await ne permet pas de déterminer quel résultat correspond au moment présent. Pour un guide axé sur React, consultez comment résoudre les conditions de concurrence que le débouncing ne peut pas résoudre.
Le fait que la demande ait été traitée ne signifie pas que le système est cohérent
Une écriture dans une base de données peut réussir même si les réplicas sont encore en retard. La publication d’un message est généralement résolue une fois que le broker l’a accepté, et non une fois qu’un consommateur l’a traité. L’envoi d’un e-mail est généralement résolu lorsque le fournisseur a accepté la demande, et non lorsque quelqu’un l’a reçu. Il est facile d’interpréter await comme indiquant davantage de confirmations que ce que l’API fournit réellement.
await publishEvent(event);
Cette ligne peut garantir que l’événement est parvenu au broker. Cela ne signifie pas nécessairement que les projections, les notifications ou les index de recherche en aval ont été mis à jour.
await sendEmail(message);
De même, cela peut simplement signifier que le fournisseur a accepté le message. Cela ne garantit ni sa livraison, ni que le destinataire l’aura lu, ni même que le fournisseur le conservera après traitement ultérieur.
La signification de « terminé » est définie par le contrat de l’API qui génère la Promise, et await ne peut pas renforcer ce contrat. Les problèmes surviennent lorsque la ligne suivante suppose qu’un changement d’état plus important a déjà eu lieu, comme un point de terminaison qui publie un événement puis interroge immédiatement un modèle de lecture géré par un consommateur, en s’attendant à ce que les nouvelles données soient déjà présentes. La Promise s’est bien exécutée ; l’architecture est simplement encore en train de se mettre à jour. Selon le produit, une conception adéquate pourrait retourner un statut en attente, lire plutôt dans le modèle d’écriture, attendre une confirmation explicite, ou adopter une cohérence éventuelle dans l’interface utilisateur.
Concevoir des Promises qui correspondent à leur nom
La plupart des confusions liées à await disparaissent dès qu’une question devient une habitude : la valeur que j’attends représente-t-elle l’achèvement de toute l’opération qui m’intéresse ? En examinant les cas précédents, la réponse est clairement non dans les situations suivantes :
- une fonction crée des Promises enfants sans les retourner ni les attendre
- une méthode d’array élimine les Promises renvoyées par ses callbacks asynchrones
- une API renvoie un identifiant et indique l’achèvement par des événements
- un émetteur se résout avant que les consommateurs n’aient terminé leur travail
La présence de await prouve uniquement que la fonction en cours s’est arrêtée en attendant que une valeur de type Promise soit résolue. Elle ne prouve pas que le travail associé est terminé, que les effets secondaires se sont produits exactement une fois, que le travail précédent a été annulé, que les erreurs restent liées, ou que le résultat est encore d’actualité.
C’est pourquoi bien concevoir du code asynchrone relève principalement d’un problème de conception d’API. La Promise retournée par une fonction doit se résoudre au moment correspondant à la responsabilité indiquée par son nom. Une fonction nommée saveDocument doit faire clairement comprendre si sa Promise concerne la persistance locale, la synchronisation à distance ou les deux. Une fonction nommée scheduleReport peut légitimement se résoudre lors de l’acceptation de la tâche plutôt que lors de l’achèvement du rapport, mais son nom et son type de retour doivent le préciser, par exemple en se résolvant avec un identifiant de tâche que l’appelant peut surveiller.
Ce que await peut et ne peut pas promettre
awaitévalue une expression et attend que cette seule valeur se résolve ; il ne connaît rien de « la tâche » dans son ensemble.- Le lieu où vous appelez les fonctions asynchrones détermine si le travail s’exécute en parallèle ;
awaitne détermine que le moment où vous reprenez l’exécution. - Le travail qui n’est ni retourné ni attendu est dissocié : ses erreurs, son délai d’exécution et son achèvement restent invisibles pour l’appelant.
- Les API de rappel, d’événement et de flux doivent être enveloppées dans des Promises qui indiquent un véritable achèvement avant que
awaitait un sens. Promise.allne annule ni ne révise rien ; choisissezallSettled, une compensation ouAbortSignalen fonction des besoins sémantiques que vous avez.- L’achèvement, la pertinence et la cohérence sont trois garanties distinctes, et seule la première provient de
await. - Considérez chaque Promise que vous retournez comme un contrat, et assurez-vous que son achèvement signifie exactement ce que le nom de la fonction implique.
Lectures complémentaires
- Refactoring des chaînes de Promise en async/await sans changer le comportement : Comment transformer les chaînes .then() en code async/await lisible, ce que attend vraiment pause, quand exécuter les étapes en parallèle, et comment try/catch remplace .catch().
- Misconceptions courantes sur async/await qui causent des bugs en production : Explication de neuf malentendus subtils concernant async/await, allant des conditions de course aux rejets non gérés, qui endommagent silencieusement les applications JavaScript en environnement réel.