Exécutez ceci dans Node ou dans la console d’un navigateur :
| |
Le résultat est 1 4 3 2. Un setTimeout avec un délai de 0ms s’exécute quand même après le callback de la promise, et les deux attendent que chaque ligne de code synchrone en dessous se termine. async/await repose sur cet ordre, ce n’est pas un mécanisme différent. La plupart des comportements surprenants du JavaScript asynchrone viennent du fait qu’on écrit await comme s’il mettait en pause tout le programme, alors qu’il ne met en pause que la fonction dans laquelle il se trouve.
Pourquoi setTimeout s’exécute-t-il après une promise sans délai ?
JavaScript exécute votre code sur un seul thread, avec une seule call stack. Les fonctions s’y empilent quand elles sont appelées et s’en dépilent quand elles retournent, et rien d’autre ne s’exécute tant que la stack contient quelque chose. Les timers, les réponses réseau et les résolutions de promise sont gérés par l’environnement d’exécution autour de la stack (le navigateur, ou libuv dans Node), qui met un callback en file d’attente pour plus tard.
La file dans laquelle atterrit le callback dépend du type de travail concerné :
- La task queue (aussi appelée macrotask queue) contient les callbacks de
setTimeoutetsetInterval, les fins d’opérations I/O et les événements d’interface utilisateur. L’event loop prend exactement une task de cette file à chaque itération. - La microtask queue contient les callbacks de promise (
.then,.catch,.finally) et tout ce qui est planifié avecqueueMicrotask. L’event loop vide complètement cette file, y compris les microtasks ajoutées pendant qu’elle la vide, avant de passer à la suite.
Une itération de la boucle ressemble à ceci :
| |
Ainsi, dans l’extrait ci-dessus, console.log("4") termine l’exécution synchrone, la microtask queue se vide et affiche 3, et ce n’est qu’ensuite que la boucle traite le callback du timer qui affiche 2. Si vous avez déjà travaillé avec une file de messages, le modèle vous est familier : le travail attend en file et un seul consommateur traite un élément à la fois. La différence, c’est que JavaScript a deux files, et la file des microtasks est toujours servie en premier.
Un effet de bord à connaître : puisque la microtask queue doit être vide avant qu’une task puisse s’exécuter, une chaîne de promises qui planifie sans cesse de nouveaux callbacks affame les timers et, dans un navigateur, bloque les repaints.
Ce qu’await met vraiment en pause
Une fonction async retourne toujours une promise, même sans return et sans await dans son corps. await met en pause cette seule fonction : tout ce qui suit devient une continuation qui s’exécute comme une microtask une fois que la valeur attendue est résolue. C’est du sucre syntaxique au-dessus de .then(), pas un mécanisme différent.
| |
Cela affiche start, a, end, b. example() s’exécute de façon synchrone jusqu’à await null, puis rend le contrôle à son appelant. console.log("end") s’exécute avant que example ne reprenne, parce que la reprise est mise en file comme une microtask, et les microtasks attendent que le code synchrone en cours se termine.
Ce seul fait, await cède la main à la microtask queue et à rien d’autre, explique toutes les erreurs qui suivent. C’est aussi pourquoi async/await a fait disparaître les chaînes de .then() imbriquées : une chaîne qui gère les erreurs à trois profondeurs différentes, c’est exactement le flux de contrôle enchevêtré que décrit le spaghetti code, alors que des instructions await séquentielles remettent l’ordre des opérations à plat sur la page, de haut en bas.
Que se passe-t-il si vous oubliez le mot-clé await ?
Oubliez un await et vous obtenez l’objet promise au lieu de son résultat. Aucune erreur, juste une donnée fausse trois lignes plus loin :
| |
getUser(4821) retourne immédiatement une Promise en attente, et une Promise n’a pas de propriété name. La correction tient en un mot-clé : const user = await getUser(id). Ce qui rend ce bug coûteux, c’est le silence. Rien ne lève d’erreur à la ligne fautive ; on retrouve undefined plus loin dans le flux, ou [object Promise] dans un template string.
Promise.all vs await dans une boucle
await à l’intérieur d’une boucle for termine chaque itération avant de démarrer la suivante, même quand les itérations ne dépendent pas les unes des autres :
| |
Si chaque appel à getUser prend 200ms et que ids contient trois entrées, cela prend environ 600ms. La deuxième requête ne démarre pas avant que la réponse de la première soit revenue. Quand les requêtes sont indépendantes, lancez-les toutes puis attendez le lot :
| |
Les trois requêtes partent maintenant en même temps et la fonction retourne en environ 200ms, le temps de la plus lente. Promise.all rejette dès qu’une seule promise est rejetée. Si vous avez besoin de tous les résultats indépendamment des échecs individuels, Promise.allSettled vous donne un statut pour chacune au lieu de court-circuiter.
Un await séquentiel est le bon choix dans deux cas. D’abord, quand chaque itération a besoin du résultat précédent, comme paginer une API avec un curseur issu de la dernière réponse. Ensuite, quand vous limitez volontairement le débit face à un endpoint rate-limited qui rejetterait une rafale d’appels concurrents. Pour un grand tableau ids, Promise.all peut se heurter lui-même à ce second problème, donc plafonnez la concurrence avec un petit pool ou une bibliothèque comme p-limit.
Pourquoi un forEach async n’attend-il pas ?
forEach appelle son callback pour chaque élément et jette ce que le callback retourne, promise incluse :
| |
"done" s’affiche avant le moindre nom d’utilisateur. forEach démarre les trois callbacks async et retourne sans attendre aucun d’eux, et on ne peut pas lui appliquer await puisqu’il retourne undefined, pas une promise. Pour un traitement séquentiel, utilisez for...of avec await à l’intérieur. Pour un traitement concurrent, utilisez la même correction que pour la boucle ci-dessus :
| |
Cela fonctionne parce que map retourne un tableau de promises, exactement ce que Promise.all attend en entrée.
Une rejection non gérée fait planter Node
Une fonction async qui lève une erreur produit une promise rejetée, pas une exception dans le stack frame de l’appelant :
| |
Dans un onglet de navigateur, cela affiche Uncaught (in promise) dans la console et la page continue de fonctionner. Node est plus strict : depuis Node 15, une rejection non gérée est traitée par défaut comme une exception non attrapée, et le processus se termine. Gérez les rejections là où elles se produisent, avec un try/catch autour de l’await ou un .catch() sur l’appel. Ajoutez ensuite un gestionnaire global, en filet de sécurité de dernier recours :
| |
Enregistrer ce listener remplace le crash par défaut de Node : le processus continue de tourner après une rejection qu’il n’a pas su gérer. Utilisez-le pour logger puis arrêter proprement (appelez process.exit(1) après avoir vidé les logs), pas pour masquer des gestionnaires manquants.
Pourquoi try/catch n’attrape-t-il pas les erreurs dans les callbacks ?
Un try/catch ne voit que les exceptions levées pendant que son bloc est en cours d’exécution. Le code planifié pour plus tard, dans une autre task ou microtask, est hors de portée même s’il est écrit à la ligne suivante :
| |
setTimeout planifie le callback puis retourne, si bien que le bloc try se termine immédiatement. Quand le callback lève une erreur 100ms plus tard, il s’exécute sur une call stack toute neuve, sans aucun catch au-dessus de lui.
await fonctionne différemment. La fonction async est suspendue, pas terminée : son état local et son bloc try sont conservés, et quand la promise attendue est rejetée, la fonction reprend en levant la rejection à la ligne de l’await, à l’intérieur du try :
| |
Remarquez le return await. Avec un simple return getUser(id), la fonction renvoie directement la promise sans l’attendre, et une rejection contourne entièrement ce catch.
Détecter ces bugs avant qu’ils partent en production
La plupart de ces erreurs sont détectables de façon statique, donc laissez le linter les repérer plutôt que l’event loop. Dans ESLint, activez no-await-in-loop pour signaler les awaits séquentiels (puis désactivez-le en ligne là où le séquencement est voulu) et no-async-promise-executor. Si vous utilisez TypeScript, ajoutez @typescript-eslint/no-floating-promises et @typescript-eslint/no-misused-promises : le premier repère les promises qui ne sont ni attendues ni gérées, le second repère les callbacks async passés à des endroits comme forEach qui ignorent la promise retournée. Quand la sortie arrive encore dans un ordre inattendu, notez dans quelle file atterrit chaque callback avant de toucher au code.