Ce que async/await garantit réellement, et ce qu’il vous laisse à gérer
Comprendre ce que suspend réellement await, comment éviter les requêtes sérialisées, et pourquoi les erreurs, les annulations, le tri et les tentatives de réessai nécessitent des approches allant au-delà d’async/await.
await ressemble à un script qui s’exécute ligne par ligne, et c’est précisément cette impression qui est à l’origine de nombreux bugs asynchrones. Des tableaux de bord lents, des erreurs masquées, des résultats de recherche obsolètes et des paiements facturés deux fois proviennent souvent du fait d’attendre de async/await des garanties qu’il n’a jamais offertes. Une fois que vous connaissez le contrat précis et relativement simple derrière cette syntaxe, vous pouvez en un coup d’œil distinguer quel comportement provient du langage et lequel vous devez encore concevoir.
Voici une fonction qui semble clairement séquentielle :
async function loadDashboard(userId) {
const user = await fetchUser(userId);
const projects = await fetchProjects(userId);
const notifications = await fetchNotifications(userId);
return {
user,
projects,
notifications,
};
}
La lecture naturelle est : récupérer l’utilisateur, attendre, récupérer les projets, attendre, récupérer les notifications, puis renvoyer. Cette lecture n’est pas tout à fait fausse, mais elle cache l’aspect essentiel lorsque le code doit être rapide, concurrent, annulable ou résistant aux pannes.
await ne dit rien sur la manière dont le travail sous-jacent est effectué. Il se contente de marquer l’endroit où la fonction async qui le contient doit être suspendue jusqu’à ce qu’une promesse soit résolue. Tant que cette fonction est en suspension, le moteur de exécution continue de traiter d’autres tâches. De nombreuses surprises surviennent lorsqu’on attribue au comportement de async/await des caractéristiques qui appartiennent en réalité aux promesses, à l’environnement hôte, à une API comme fetch, à une stratégie de concurrence ou encore à l’application elle-même.
await suspend une fonction, pas tout le moteur de exécution
Considérez ce petit programme qui enregistre des informations autour d’une appel réseau :
async function loadUser() {
console.log("A");
const user = await fetch("/api/user");
console.log("B");
return user;
}
console.log("1");
loadUser();
console.log("2");
L’appel à loadUser() fait immédiatement exécuter son corps, ce qui explique pourquoi "A" est enregistré en premier, après "1". Lorsque l’exécution atteint le await, la fonction ne bloque pas le thread pendant que la requête est en cours. Elle se suspend elle-même et restitue le contrôle à son appelant, c’est pourquoi "2" apparaît avant "B". Une fois la promesse de fetch résolue, le reste de loadUser() est mis en file d’attente pour reprendre son exécution, et ce n’est qu’à ce moment-là que "B" est enregistré. L’ordre résultant est 1, A, 2, B.
Ainsi, la traduction correcte de await n’est pas « arrêter JavaScript ici ». Elle correspond plutôt à « tout ce qui suit cette ligne dépend de cette valeur, donc continuez l’exécution de cette fonction plus tard ».
Cela reste vrai même lorsque la valeur attendue est déjà disponible. L’attente d’une promesse exécutée ou d’une valeur non prometteuse reporte néanmoins l’exécution du reste de la fonction à une microtâche ultérieure au lieu de continuer en ligne, comme le précise MDN. C’est pourquoi l’ajout d’expressions await supplémentaires apparemment inoffensives peut modifier le planification : chacune ajoute une nouvelle frontière de microtâche.
La conséquence pratique est que l’on ne peut pas comprendre un programme en traitant chaque await comme une appel bloquant dans une fonction synchrone. Il faut suivre ce qui a déjà été lancé, quelles fonctions sont actuellement suspendues, et ce qui peut encore s’exécuter avant que une fonction donnée ne reprend.
async ne déplace pas le travail hors du thread principal
Le mot-clé async engendre une autre idée fausse. Regardons cette boucle gourmande en ressources CPU :
async function calculateTotal(items) {
let total = 0;
for (const item of items) {
total += expensiveCalculation(item);
}
return total;
}
Rien dans ce code n’est concurrentiel. L’ajout de async ne place pas expensiveCalculation() sur un autre thread, ne parallélise pas la boucle, et n’empêche pas les tâches synchrones gourmandes de bloquer tout ce qui souhaite s’exécuter sur le même thread.
Ce que async modifie, c’est le contrat de retour de la fonction. Son appel génère toujours une promesse, et le retour d’une valeur ordinaire remplit cette promesse avec ladite valeur. La spécification ECMAScript décrit l’évaluation des fonctions async en termes d’une capacité de promesse qui devient le résultat de la fonction.
async function getNumber() {
return 42;
}
const result = getNumber();
console.log(result);
// Promise
Le journalage de result affiche une Promise en attente ou remplie, et non 42. On ne obtient ce nombre qu’en attendant la promesse ou en appelant .then() dessus.
Cependant, renvoyer une promesse ne rend pas le corps de la fonction asynchrone. Tout ce qui se trouve avant le premier await s’exécute de manière synchrone au moment de l’appel. Si vous placez un calcul coûteux à l’intérieur d’une fonction asynchrone en espérant que l’interface reste réactive, vous serez déçu : la fonction renverra bien une promesse à un moment donné, mais le travail lourd occupe d’abord la thread. Pour des tâches véritablement gourmandes en CPU, les outils disponibles sont les Web Workers dans le navigateur, worker_threads dans Node.js, ou encore la division du travail en blocs.
En bref, async/await est un moyen d’écrire des chaînes de promesses lues de haut en bas. Il planifie les continuations ; il ne fait jamais exécuter du code bloquant de manière concurrente.
Où placer await peut sérialiser des tâches indépendantes
Revenons au chargeur du tableau de bord :
async function loadDashboard(userId) {
const user = await fetchUser(userId);
const projects = await fetchProjects(userId);
const notifications = await fetchNotifications(userId);
return {
user,
projects,
notifications,
};
}
On suppose que fetchProjects() n’a pas besoin de user, et que fetchNotifications() n’a besoin d’aucun des résultats précédents. La fonction effectue néanmoins trois requêtes indépendantes l’une après l’autre, car la deuxième appel n’a lieu qu’après que la première promesse ait été remplie, et la troisième attend la deuxième. Le temps de latence total est la somme des trois :
fetch user
████████
fetch projects
███████████
fetch notifications
███████
Cette correction est généralement décrite comme « utiliser Promise.all() pour les exécuter en parallèle ». Une description plus précise est que toutes les opérations indépendantes doivent être lancées avant que la fonction n’attende leur résultat. Appeler les trois fonctions d’abord crée trois promesses en cours d’exécution, et ce n’est qu’à ce moment que le code attend le résultat combiné :
async function loadDashboard(userId) {
const userPromise = fetchUser(userId);
const projectsPromise = fetchProjects(userId);
const notificationsPromise =
fetchNotifications(userId);
const [user, projects, notifications] =
await Promise.all([
userPromise,
projectsPromise,
notificationsPromise,
]);
return {
user,
projects,
notifications,
};
}
Les requêtes se chevauchent désormais, et le temps de latence total est approximativement celui du processus le plus lent :
fetch user
████████
fetch projects
███████████
fetch notifications
███████
all required results available
Promise.all() prend une collection de promesses et renvoie une seule promesse qui s’accomplit lorsque toutes les promesses d’entrée se sont accomplies. Le tableau de résultats conserve l’ordre des entrées, quel que soit le moment où chaque opération s’est terminée, ce qui permet à la déstructuration en user, projects et notifications de rester correcte.
Remarquez que ce qui distinguait le code séquentiel du code concurrent n’était jamais la présence de await. C’étaient les dépendances. Lorsqu’une étape a réellement besoin du résultat d’une autre, attendre séquentiellement est le choix approprié :
const user = await fetchUser(userId);
const permissions = await fetchPermissions(user.role);
Lorsqu’il n’existe pas de telle dépendance, attendre la fin de chaque opération avant d’en lancer une autre ajoute du retard sans améliorer la correction. Ainsi, la bonne question lors d’une revue de code n’est pas « Faut-il utiliser Promise.all()? », mais plutôt « Quelles opérations dépendent des résultats précédents, et lesquelles pourraient déjà être exécutées ? »
Promise.all() coordonne les résultats ; il ne cancelle pas les tâches
Après avoir découvert Promise.all(), de nombreux développeurs se font une nouvelle idée de ce qui se passe en cas d’échec. Prenons cette version :
const [user, projects, notifications] =
await Promise.all([
fetchUser(userId),
fetchProjects(userId),
fetchNotifications(userId),
]);
Si fetchProjects() rejette prématurément, la promesse combinée est rejetée immédiatement pour cette raison au lieu d’attendre le reste. Il est tentant de penser que les deux autres requêtes sont alors arrêtées. Ce n’est pas le cas. Le rejet de la promesse globale ne annule rien qui a déjà commencé, comme l’indique explicitement MDN. Les autres opérations continuent de s’exécuter, et leurs résultats finaux sont simplement ignorés.
Avec des lectures, cela gaspille principalement des ressources. Avec des écritures, cela peut être grave :
await Promise.all([
updateProfile(),
writeAuditLog(),
sendWebhook(),
]);
Si writeAuditLog() rejette, updateProfile() a déjà pu effectuer son enregistrement et sendWebhook() est peut-être déjà en cours d’envoi. Capturer ce rejet ne revient pas en arrière pour aucune de ces opérations.
C’est un problème de conception de système, pas un problème de syntaxe. Si ces étapes doivent réussir ou échouer ensemble, vous avez besoin d’un véritable mécanisme d’atomicité : une transaction de base de données pour les modifications dans une seule base, ou, pour les systèmes distants, une combinaison d’actions compensatoires, d’opérations idempotentes et d’état de flux de travail persisté. Promise.all() vous indique quand un groupe de promesses a atteint son état final. Il ne garantit ni rollback, ni annulation, ni le fait que les effets secondaires se produisent en tant qu’unité unique. Ces garanties doivent provenir d’une autre source.
Un outil apparenté est Promise.allSettled(), qui attend chaque entrée et rapporte chaque résultat individuellement. Il est utile lorsque vous devez savoir précisément quelles étapes ont réussi, mais il ne permet pas non plus de faire un rollback.
try/catch ne prend en compte que les rejets qui lui parviennent
L’un des facteurs qui ont contribué au succès de async/await est le fait que les erreurs des promesses puissent être gérées à l’aide des structures familières try/catch:
async function loadUser(userId) {
try {
const user = await fetchUser(userId);
return user;
} catch (error) {
console.error("Failed to load user", error);
throw error;
}
}
Lorsqu’une promesse en attente échoue, l’expression await lance la raison de cet échec à l’intérieur de la fonction, et le bloc catch adjacent la reçoit exactement comme une exception synchrone.
Cela ne signifie pas pour autant que try surveille chaque opération asynchrone lancée à l’intérieur de ses accolades. Prenons par exemple la création d’un compte :
async function createAccount(input) {
try {
await saveUser(input);
sendWelcomeEmail(input.email);
return { success: true };
} catch (error) {
console.error(error);
return { success: false };
}
}
saveUser() est attendu, ce qui fait que toute erreur le concernant est capturée par le bloc catch. sendWelcomeEmail(), quant à lui, ne l’est pas. Si cette fonction renvoie une promesse qui finit par échouer, cet échec n’entre jamais dans ce flux de contrôle, car rien ne l’attend ni ne renvoie cette promesse. createAccount() a déjà pu retourner { success: true } au moment où l’envoi de l’e-mail échoue, et l’erreur apparaît alors séparément, généralement sous forme d’un échec non géré. Dans Node.js, les rejets non gérés mettent fin au processus par défaut dans les versions actuelles, il s’agit donc d’un problème réel et non simplement esthétique.
Omettre await n’est pas automatiquement une erreur. Parfois, l’e-mail est délibérément exclu du chemin de la requête. Cependant, dans une architecture de production, ce type de tâche isolée est généralement placé dans une file d’attente persistante plutôt que lancé sous forme de promesse non surveillée. La véritable erreur réside dans l’idée que le bloc try crée une barrière d’erreur autour des tâches asynchrones futures, simplement parce que l’appel s’y trouve visuellement.
Même piège à l’endroit de l’appel. Ce dernier semble protégé, mais il s’agit simplement de JavaScript sans await:
try {
loadDashboard(userId);
} catch (error) {
console.error("Dashboard failed");
}
loadDashboard() renvoie immédiatement une promesse. Toute erreur asynchrone rejettera cette promesse plus tard, après que le bloc try ait déjà terminé, de sorte que le bloc catch ne s’exécute jamais. L’appelant doit donc participer à la chaîne de promesses :
try {
await loadDashboard(userId);
} catch (error) {
console.error("Dashboard failed");
}
Alternativement, associez un gestionnaire de rejet avec .catch(). La règle de base devient simple dès lors que l’on cesse de considérer les fonctions asynchrones comme des fonctions ordinaires contenant par hasard await : leurs appels reçoivent des promesses, et les erreurs se propagent le long de ce contrat de promesse.
La cancellation est un protocole distinct
Imaginez un champ de recherche où l’utilisateur tape un caractère à la fois :
r
re
rea
reac
react
Une implémentation simpliste envoie une requête à chaque frappe, même lorsque les requêtes précédentes sont encore en attente :
async function search(query) {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`
);
return response.json();
}
Rien dans await ne stipule que la requête pour "r" doit être interrompue parce que c’est désormais la requête pour "react" qui est importante. La requête antérieure se termine malgré le fait que l’interface n’en ait plus besoin.
Pour fetch, on exprime l’annulation à l’aide de AbortController et de son AbortSignal. Cette version annule la requête précédente avant d’en lancer une nouvelle :
let controller;
async function search(query) {
controller?.abort();
controller = new AbortController();
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{
signal: controller.signal,
}
);
return response.json();
}
Le contrôleur expose un signal que les API compatibles écoutent. L’annulation de ce signal indique à fetch d’arrêter, ce qui concerne à la fois la requête elle-même et la lecture du corps de la réponse. Un détail à prendre en compte : l’appel annulé échoue avec une AbortError ; par conséquent, toute personne qui appelle search() doit reconnaître cette erreur et l’ignorer plutôt que de la montrer à l’utilisateur.
L’expression « API compatibles » est importante. Les promesses ne disposent d’aucune opération universelle d’annulation, et await ne permet pas d’arrêter ce qu’il attend. L’annulation ne fonctionne que si l’opération appelée la prend en charge et transmet le signal à ce qui effectue réellement le travail.
Vos propres fonctions peuvent adopter le même contrat en acceptant un signal et en le vérifiant entre les étapes :
async function processFile(file, { signal }) {
for (const chunk of file.chunks) {
signal.throwIfAborted();
await processChunk(chunk);
}
}
signal.throwIfAborted() lance la raison d’annulation si une demande de suppression a été faite, ce qui fait arrêter la boucle avant le traitement du prochain bloc. L’annulation fait désormais partie explicite de l’interface de la fonction, au lieu d’être quelque chose que les appels espèrent que await fera à leur place. Pour une plus grande finesse, vous pouvez également transmettre le signal à processChunk() afin qu’un bloc long puisse s’arrêter en cours de route.
Les délais suivent la même logique. Utiliser Promise.race() pour concurrencer une opération avec un chronomètre permet à l’appelant d’arrêter d’attendre, mais l’opération sous-jacente continue sauf si elle a également reçu l’ordre de s’arrêter. Arrêter l’observation et arrêter le travail sont deux choses différentes.
L’ordre local n’est pas l’ordre global
Le await séquentiel garantit bien l’ordre au sein d’une même fonction :
await saveOrder(order);
await sendConfirmation(order);
sendConfirmation() n’est même pas appelé avant que saveOrder() ne soit terminé. Cependant, cette garantie locale dit très peu sur le reste du système. Imaginez deux requêtes mettant à jour le même profil. L’une envoie :
await updateProfile({
name: "Umar",
});
Quelques millisecondes plus tard, l’autre envoie :
await updateProfile({
name: "Umar Dev",
});
Chaque appelant attend correctement sa propre mise à jour. Rien de tout cela ne détermine quel enregistrement atteint la base de données en dernier, comment les tentatives de réessai s’entrecroisent, si les deux requêtes sont traitées par des serveurs différents, ou encore si des données obsolètes finissent par écraser des données plus récentes.
La version du problème dans le navigateur correspond à la course aux données classique. La requête A commence avant la requête B mais se termine après elle, et le code qui affiche les résultats reçus affiche des informations dépassées à l’écran :
const results = await search(query);
render(results);
Aucun await supplémentaire ne peut résoudre ce problème. L’application a besoin d’une règle concernant la pertinence ou l’ordre : annuler les recherches plus anciennes, marquer les requêtes d’une version et supprimer les réponses obsolètes, comparer les identifiants avant l’affichage, ou imposer des vérifications de version là où les données sont stockées. Il faut se rappeler que await organise le flux de contrôle à l’intérieur d’une fonction ; il ne crée pas d’ordre global entre des opérations asynchrones indépendantes.
Les tentatives de réexécution révèlent ce que await n’a jamais promis
C’est lors des tentatives de réexécution qu’un modèle mental incomplet devient coûteux. Prenons d’abord un appel de paiement :
async function submitPayment(payment) {
const response = await fetch("/api/payments", {
method: "POST",
body: JSON.stringify(payment),
});
return response.json();
}
Supposons que la requête expire et qu’un développeur ajoute une tentative de réexécution naïve :
try {
return await submitPayment(payment);
} catch {
return await submitPayment(payment);
}
Cela suppose que la tentative échouée n’a eu aucun effet. Un délai d’attente signifie simplement que le client n’a pas reçu de réponse à temps. Le serveur a pu débiter la carte sans envoyer de réponse, ou la connexion a pu se couper après que l’effet secondaire ait déjà été appliqué. Tenter à nouveau de manière aveugle peut faire payer le client deux fois.
await ne permet pas de déterminer si une tentative supplémentaire est sûre. Une promesse indique simplement si cette tentative a abouti à une exécution réussie ou à un rejet observable par l’appelant. Elle ne précise pas si le système distant a effectué des actions irréversibles avant cet outcome.
Pour les opérations ayant des effets secondaires, la sécurité des tentatives répétées doit être intégrée dans l’opération elle-même, généralement grâce à l’idempotence. Le client génère une clé stable une fois par tentative de paiement logique et l’envoie avec chaque tentative de réessai :
await fetch("/api/payments", {
method: "POST",
headers: {
"Idempotency-Key": paymentAttemptId,
},
body: JSON.stringify(payment),
});
La clé n’est utile que si le serveur la respecte : il doit enregistrer cette clé avec le résultat et, lorsque la même clé arrive à nouveau, restituer le résultat initial au lieu de facturer à nouveau. La clé doit également rester identique lors des tentatives répétées d’une même opération ; en générer une nouvelle à chaque demande, on annule l’objectif visé. L’implémentation diffère d’un système à l’autre, mais le principe reste le même : la sémantique des tentatives répétées appartient à l’opération et aux systèmes qui exécutent ses effets secondaires, et non à async/await. La gestion côté serveur est abordée dans comprendre les clés d’idempotence dans les endpoints POST de Node.js.
Une habitude précieuse en JavaScript de production découle de cela. Chaque fois qu’une appelation en attente échoue, séparez les deux affirmations suivantes : « Je n’ai pas reçu de réponse positive » et « l’opération n’a définitivement pas eu lieu ». Ce ne sont pas les mêmes affirmations.
Un modèle mental plus simple et plus précis
Vous n’avez pas besoin de mémoriser la spécification ECMAScript pour bien comprendre async/await. Il vous suffit d’un principe fondamental : une fonction async renvoie une promesse ; son corps s’exécute normalement jusqu’à ce qu’il atteigne un await ; le await suspend cette fonction jusqu’à ce que la valeur attendue soit disponible, puis planifie sa reprise.
Tout le reste constitue une question distincte avec sa propre réponse :
- Plusieurs opérations : quand chacune commence-t-elle, et dépend-elle d’une autre ? Cela vous indique si l’ordre des exécutions est intentionnel ou accidentel.
try/catch protège bien ce que vous croyez.await ne le peuvent pas ?Points clés
async/await est délibérément simple. Il rend le flux de contrôle asynchrone lisible, ce qui est extrêmement précieux, mais cette même lisibilité fait que le code paraît plus synchrone que le système sous-jacent ne l’est réellement. Lorsque vous rencontrez un await, évitez de l’interpréter comme « le programme attend ici ». Considérez-le plutôt comme un signal : cette fonction est en attente d’une valeur, donc quelles opérations sont déjà en cours, qu’est-ce qui peut s’exécuter entre-temps, et quelles garanties votre propre conception doit-elle fournir ? Cette question correspond à ce que la syntaxe fait réellement, et elle vous conduit directement aux décisions de conception qui permettent d’éviter les bugs en production.
Lectures complémentaires
- Async/Await vs Promises : Quelles sont réellement les différences en profondeur — Explique les véritables différences en termes d’exécution, de mémoire et d’empreinte sur la pile entre async/await et Promises, ainsi que les cas où il convient encore d’utiliser les API natives de Promise.
- Idées reçues courantes sur async/await qui causent des bugs en production — Décrit neuf malentendus subtils concernant async/await, allant des conditions de concurrence aux rejets non gérés, qui endommagent silencieusement les applications JavaScript en environnement réel.