Quand await casse tout : comprendre le duo async/await en profondeur (et éviter les pièges)
Nous avons tous vu passer des bouts de code où await rend l’asynchronisme presque invisible, comme si JavaScript devenait synchrone par magie. Pourtant, il arrive souvent qu’un développeur tente un await dans une fonction ordinaire et se heurte à une erreur brutale : SyntaxError: await is only valid in async functions. Ce point avait déjà plus ou moins été évoqué dans l’article L’Évolution des Requêtes HTTP et des Opérations Asynchrones.
Dans le présent article, nous reprenons donc le fil, mais en l’approchant sous un autre angle : comprendre pourquoi cette limitation existe et ce qui se joue en coulisses. Dans cet article, il ne s’agit pas seulement de rappeler que async et await vont ensemble, mais de montrer pas à pas comment et pourquoi, à travers des exemples concrets, des pièges fréquents, et quelques astuces pour en faire un usage sûr et lisible.
Rappel rapide minimal
Pour bien comprendre l’intérêt de async et await, reprenons les bases. JavaScript n’exécute qu’un seul fil d’instructions : si une opération prenait trop longtemps, tout le reste du programme serait bloqué. Pour éviter cela, le langage a introduit un mécanisme : les promesses (Promise). Une promesse représente une valeur qui sera disponible plus tard, quand l’opération sera terminée. On peut la voir comme un ticket numéroté : on s’inscrit à la file d’attente et on sera rappelé quand viendra notre tour.
Prenons un exemple concret. Nous appelons une API pour obtenir des données :
fetch("/api/data")
.then(reponse => reponse.json())
.then(contenu => console.log(contenu));Ici, fetch renvoie une promesse. Avec .then(), nous disons : « quand la réponse sera prête, transforme-la en JSON, puis affiche le résultat ». La méthode .json() lit le corps de la réponse HTTP et renvoie une nouvelle promesse qui se résout avec l’objet JavaScript obtenu. Le nom contenu n’est donc qu’une étiquette pour ce résultat final, déjà prêt à être utilisé. Cela fonctionne, mais n’est pas toujours ce que l’on attend intuitivement. Beaucoup s’attendraient à écrire :
let reponse = fetch("/api/data");
console.log(reponse);…et à voir directement les données apparaître. Or, ce n’est pas le cas : reponse contient une promesse en attente, pas encore la valeur finale. Pour contourner cela, on doit chaîner avec .then(), mais alors le code devient vite lourd et difficile à lire, surtout si l’on ajoute plusieurs étapes successives.
C’est précisément pour rapprocher l’écriture de ce que nous serions en droit d’attendre, une lecture séquentielle, comme si le code était synchrone, que async et await ont été introduits. Ils ne changent pas la mécanique sous-jacente, mais offrent un moyen d’exprimer plus clairement cette attente et de garder un code lisible. C’est justement ce que nous allons explorer au chapitre suivant : voir comment async et await travaillent ensemble pour répondre à cette attente.
Le cœur : comment async et await fonctionnent ensemble
Le rôle de async
Quand nous déclarons une fonction avec le mot-clé async, nous lui donnons un comportement particulier. Même si la fonction retourne une valeur simple, elle sera automatiquement enveloppée dans une promesse. Cela signifie que async transforme toujours le résultat en promesse, prête à être attendue par un await.
async function renvoyerNombre() {
return 42;
}
console.log(renvoyerNombre());À première vue, on pourrait penser que la fonction renvoie directement le nombre 42, mais ce n’est pas le cas. Ce que l’on obtient réellement est une promesse qui se résout avec la valeur 42. Si l’on veut accéder à cette valeur, il faut utiliser await à l’intérieur d’une fonction asynchrone :
async function test() {
let resultat = await renvoyerNombre();
console.log(resultat); // 42
}Ainsi, async prépare le terrain en garantissant que la fonction retourne une promesse. Sans cette promesse, await ne saurait pas quoi attendre et provoquerait une erreur. C’est cette relation que nous allons explorer en ajoutant maintenant le rôle complémentaire de await.
Le rôle de await
Le mot-clé await est l’autre moitié du duo. Il s’utilise uniquement dans une fonction déclarée avec async. Son rôle est de mettre en pause l’exécution de cette fonction jusqu’à ce que la promesse passée en argument soit résolue, puis de reprendre avec la valeur obtenue.
async function chargerDonnees() {
let reponse = await fetch("/api/data");
let contenu = await reponse.json();
console.log(contenu);
}Ici, la fonction s’exécute normalement jusqu’au premier await. À ce moment, JavaScript met de côté l’exécution de la fonction chargerDonnees et continue avec le reste du programme. Lorsque la promesse renvoyée par fetch est résolue, la fonction reprend à l’endroit où elle avait été suspendue et reponse reçoit sa valeur. Le même mécanisme se produit avec reponse.json().
Ce qui est important, c’est que await n’arrête pas tout JavaScript, il ne bloque que la fonction asynchrone dans laquelle il se trouve. C’est cette suspension locale qui rend le code plus lisible et plus proche d’un déroulement séquentiel, sans bloquer l’application entière.
Pourquoi await seul dans une fonction non async plante
Essayons maintenant d’utiliser await dans une fonction ordinaire :
function demo() {
let reponse = await fetch("/api/data");
console.log(reponse);
}Dès l’exécution, JavaScript renvoie une erreur :
SyntaxError: await is only valid in async functionsPourquoi ? Parce qu’en dehors d’une fonction async, le moteur JavaScript ne sait pas comment suspendre et reprendre l’exécution. Il s’attend à ce que chaque instruction s’enchaîne immédiatement. Le mot-clé await suppose au contraire que l’on puisse mettre en pause, attendre la résolution d’une promesse et reprendre plus tard. Sans async pour préparer ce cadre, cela est impossible.
async function demo() {
let reponse = await fetch("/api/data");
console.log(reponse);
}Ici, tout fonctionne. Le mot-clé async signale au moteur qu’il devra gérer des pauses éventuelles et envelopper le résultat dans une promesse. await peut alors jouer son rôle, et la fonction devient un bloc capable de s’arrêter et de reprendre sans bloquer le reste du programme.
Coulisses internes
En arrière-plan, async active une mécanique interne propre au moteur JavaScript. Le code est transformé en une sorte de machine d’état qui sait se suspendre et se reprendre plus tard. Ce fonctionnement repose sur des concepts proches des générateurs, c’est-à-dire de fonctions capables de produire des valeurs étape par étape et de reprendre là où elles s’étaient arrêtées, même si le langage masque cette complexité.
Pour ceux qui souhaitent approfondir ce sujet, plusieurs ressources apportent des éclairages complémentaires :
- La documentation MDN : async function – MDN.
- Un fil de discussion riche sur StackOverflow : How async/await internally work.
- L’article Programmation asynchrone avec async et await – Gayerie.dev, qui propose une explication détaillée et illustrée de cette transformation interne.
- Une présentation claire des générateurs et itérateurs classiques sur JavaScript.info, utile pour comprendre les bases avant de plonger dans leur version asynchrone.
Ces détours techniques aident à comprendre pourquoi await ne peut pas fonctionner seul : il a besoin du cadre préparé par async pour que le moteur sache où suspendre et où reprendre l’exécution.
Exemples progressifs d’utilisation de async et await
Nous allons maintenant avancer par petits pas, en illustrant la manière dont async et await simplifient le code et le rendent plus lisible. Chaque exemple ajoutera une couche de complexité pour mieux comprendre leur intérêt.
Cas trivial
Imaginons une promesse très simple, par exemple attendre la réponse d’un serveur qui confirme qu’un utilisateur est bien connecté :
function promesseSimple() {
return Promise.resolve("ok");
}
async function test() {
let resultat = await promesseSimple();
console.log(resultat);
}Ici, tout est limpide. La promesse se résout immédiatement, et grâce à await nous obtenons directement la valeur « ok ».
Enchaînement séquentiel
Supposons maintenant deux étapes qui dépendent l’une de l’autre, comme récupérer le profil d’un utilisateur puis calculer un score basé sur ses données :
async function chaine() {
let valeur = await Promise.resolve(10);
let double = await Promise.resolve(valeur * 2);
console.log(double);
}Grâce à await, le code se lit comme une suite d’instructions ordinaires. Nous attendons chaque étape avant de passer à la suivante, ce qui rend la logique très claire.
Comparaison avec l’exécution parallèle
Mais que se passe-t-il si les opérations n’ont pas besoin de s’attendre ? Dans ce cas, on peut utiliser Promise.all pour lancer les promesses en parallèle :
async function parallele() {
let [a, b] = await Promise.all([
Promise.resolve(2),
Promise.resolve(3)
]);
console.log(a + b);
}Cette approche montre bien que await sert à écrire du code lisible, mais qu’il ne faut pas oublier les outils classiques comme Promise.all pour éviter de ralentir inutilement quand les opérations sont indépendantes.
Cas mixte ou piège : mélanger then et await
Dans la pratique, on rencontre parfois du code qui mélange des appels avec .then() et d’autres avec await. Cela arrive souvent lorsqu’on adapte du code existant écrit avec des promesses vers un style async/await. Le résultat peut vite devenir confus.
Prenons un exemple simple :
async function melange() {
let reponse = await fetch("/api/data");
reponse.json().then(contenu => {
console.log("Profil reçu", contenu);
});
console.log("Fin de la fonction");
}Ici, on utilise await pour la réponse, mais .then() pour traiter le JSON. Le souci est que la lecture devient plus difficile : il faut se rappeler que console.log("Fin de la fonction") s’exécutera avant l’affichage du profil.
La version cohérente en await est beaucoup plus claire :
async function melangeCorrige() {
let reponse = await fetch("/api/data");
let contenu = await reponse.json();
console.log("Profil reçu", contenu);
console.log("Fin de la fonction");
}Le piège n’est pas que le code soit faux, mais que la logique devienne difficile à suivre. Pour plus de lisibilité, il est préférable de rester cohérent : soit tout en then, soit tout en await dans une même fonction.
Cas « top-level await »
Jusqu’ici, nous avons toujours utilisé await à l’intérieur d’une fonction marquée async. Mais depuis les modules ES2022, il est aussi possible d’utiliser await directement au niveau supérieur, sans fonction englobante. On parle alors de « top-level await ».
Exemple :
// fichier module.mjs
let reponse = await fetch("/api/data");
let contenu = await reponse.json();
console.log("Profil reçu", contenu);Ce code fonctionne si le fichier est reconnu comme module (extension .mjs ou balise <script type="module">). await est alors autorisé à ce niveau. Cela peut simplifier certains scripts, notamment quand on n’a pas envie d’envelopper tout le code dans une fonction async.
Attention toutefois : le support dépend de l’environnement. Les navigateurs récents et Node.js moderne l’acceptent, mais d’anciens contextes ne le reconnaissent pas. Il faut donc savoir si votre projet peut en bénéficier avant de l’utiliser.
Parcourir des flux asynchrones avec for await...of
Un autre usage intéressant d’await est la boucle for await...of. Elle permet d’itérer sur une série de promesses comme si l’on parcourait un tableau classique. C’est très pratique lorsqu’on reçoit des données par morceaux, ou lorsqu’on veut traiter une liste d’appels asynchrones.
async function traiterFlux() {
const urls = ["/api/u1", "/api/u2", "/api/u3"];
const promesses = urls.map(u => fetch(u));
for await (let reponse of promesses) {
let contenu = await reponse.json();
console.log("Utilisateur", contenu);
}
}Ici, chaque requête est lancée en parallèle grâce à map. La boucle for await...of s’assure que nous traitons les réponses les unes après les autres, dans l’ordre où elles apparaissent dans la liste. Ce mécanisme simplifie énormément la lecture d’un flux asynchrone.
Ce type de boucle fonctionne aussi avec des objets dits « async iterable », qui produisent des valeurs au fil du temps. C’est une porte d’entrée vers des scénarios plus avancés, comme la lecture de flux réseau ou de fichiers par morceaux.
Autres usages pratiques
await peut aussi être utilisé pour des scénarios très simples qui rendent le code plus clair. Par exemple, simuler une pause avec setTimeout :
function attendre(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function demoPause() {
console.log("Début");
await attendre(1000);
console.log("Une seconde plus tard");
}De même, await fonctionne parfaitement dans un bloc try/catch pour gérer les erreurs :
async function demoErreur() {
try {
let reponse = await fetch("/api/introuvable");
let contenu = await reponse.json();
console.log(contenu);
} catch (erreur) {
console.error("Problème détecté", erreur);
}
}Ces petits cas d’usage montrent que await n’est pas réservé aux appels réseau, mais qu’il peut rendre lisible toute attente ou tout traitement susceptible d’échouer.
Ouvertures et approfondissements
Pour aller un peu plus loin, il est intéressant d’ouvrir quelques pistes qui dépassent l’usage immédiat de async et await.
Une première concerne l’asynchronisme poussé. La boucle for await...of est une porte d’entrée vers les itérateurs asynchrones, qui permettent de travailler avec des flux continus de données, comme des fichiers lus par morceaux ou des flux réseau. Cela ouvre des scénarios plus riches que les simples appels ponctuels. Pour ceux qui souhaitent explorer plus en détail les générateurs et itérateurs asynchrones, l’article Itérateurs et générateurs asynchrones – JavaScript.info constitue une ressource précieuse.
Il existe aussi des discussions autour de l’usage intensif d’async et await. Trop de fonctions marquées async peuvent compliquer la maintenance d’un projet, surtout quand on enchaîne les appels sans réfléchir au parallélisme. C’est pourquoi des outils comme Promise.all restent précieux pour éviter de ralentir inutilement le code.
Ces perspectives montrent qu’async et await ne sont pas seulement des raccourcis de syntaxe, mais une manière cohérente de penser l’écriture d’un programme asynchrone.
Conclusion
async et await forment un duo indissociable qui simplifie considérablement l’écriture du code asynchrone en JavaScript. Comprendre pourquoi await ne fonctionne pas seul, comment async prépare le terrain et de quelle manière le moteur suspend puis reprend l’exécution permet d’aller au-delà de la simple syntaxe.
En pratique, la règle essentielle reste simple : si vous utilisez await, placez-le toujours dans une fonction async. Ce principe garantit que votre code restera cohérent, lisible et prévisible, tout en profitant de la puissance de l’asynchronisme.
Ce n’est pas seulement une question de confort de lecture, mais une manière d’adopter une approche claire et maîtrisée du développement moderne en JavaScript.
