Accueil / Articles

Async/Await contre Promises : en quoi diffèrent-ils réellement au niveau technique ?

En quoi diffèrent réellement async/await et Promises en JavaScript : l’ordre d’exécution, la mémoire, les traces d’erreur, et les cas où les API natives de Promise restent préférables dans Node.js.

Éditeur : REACTAPP.TOP10 min de lecture2366 mots

Sources: Async/Await vs Promises — The Real Difference Nobody Explains Clearly

Assez avec l’argument « ce n’est que du sucre syntaxique ». Voici ce qui se passe réellement en arrière-plan concernant l’exécution, la mémoire, les traces d’erreur et la concurrence.

Si vous demandez à un groupe de développeurs JavaScript d’expliquer en quoi Promises diffèrent d’async/await, la plupart réciteront la même phrase que vous avez entendue des centaines de fois :

« async/await n’est rien d’autre qu’une couche esthétique ajoutée par-dessus Promises — elle permet simplement au code asynchrone d’être lu comme s’il était exécuté de manière synchrone. »

Cette affirmation n’est pas tout à fait fausse, mais c’est une simplification qui cache plus de choses qu’elle n’en révèle. Décrire async/await comme « simplement du sucre syntaxique » revient un peu à décrire une boîte de vitesses automatique comme « juste un panneau décoratif fixé sur une boîte manuelle ». Cela passe sous silence le fonctionnement réel du mécanisme sous-jacent, la manière dont les erreurs se propagent dans le système, l’allocation de la mémoire, et pourquoi choisir un style plutôt qu’un autre peut changer significativement le comportement de votre application une fois qu’elle est en production.

Si les deux approches étaient vraiment interchangeables à tous les niveaux, posez-vous ces questions :

  • Pourquoi les traces d’erreur ont-elles l’air complètement différentes lorsqu’une panne provient à l’intérieur d’un await par rapport à celle provenant d’un .catch() ?
  • Pourquoi les captures d’écran de la mémoire révèlent-elles un comportement de conservation différent entre une chaîne d’appels .then() et une fonction async équivalente ?
  • Pourquoi les ingénieurs expérimentés continuent-ils d’utiliser les API brutes de Promise, même dans des bases de code entièrement construites autour de async/await ?
  • Laissons de côté les explications simplifiées et examinons ce qui se passe réellement à l’intérieur du moteur JavaScript lorsque du code asynchrone s’exécute.

    1. Le modèle mental de base : Envoi vs. Pause

    Pour comprendre pourquoi ces deux approches semblent si différentes lorsqu’on les écrit puis qu’on les débogue, il faut saisir les modèles d’exécution distincts sur lesquels chacune repose.

    RAW PROMISE (.then CHAIN)             ASYNC / AWAIT (COROUTINE)
    ===========================             =========================
    
    +-----------------------+               +-----------------------+
    |  Initiate Task        |               |  Initiate Task        |
    +-----------+-----------+               +-----------+-----------+
                |                                       |
    +-----------v-----------+               +-----------v-----------+
    | Register Callback     |               | Yield & Pause Frame   |
    | (Handler pushed away) |               | (Thread freed up)     |
    +-----------+-----------+               +-----------+-----------+
                |                                       |
    +-----------v-----------+               +-----------v-----------+
    | Main Stack Clears     |               | Resume on Resolution  |
    | (Context lost)        |               | (Context restored)    |
    +-----------------------+               +-----------------------+
    

    Le modèle Promise : Enregistrement de callbacks (Envoi)

    Une Promise est essentiellement un objet qui représente le résultat futur — succès ou échec — d’une tâche asynchrone.

    Prenons une chaîne typique .then() :

    function getUserData(userId) {
      return fetchUser(userId)
        .then(user => fetchOrders(user.orderId))
        .then(orders => calculateTotal(orders))
        .catch(err => handleError(err));
    }
    

    Rien ici ne s’arrête réellement. La fonction getUserData s’exécute de manière synchrone, du début à la fin, en l’espace de quelques microsecondes. Ce qu’elle fait réellement, c’est créer une Promise, y associer des fonctions de rappel qui seront exécutées une fois ces Promises résolues, puis quitter immédiatement la pile d’appels.

    Plus tard, une fois la requête réseau terminée, le cycle d’événements retire la fonction de rappel enregistrée de la file des microtâches et la place sur une pile d’appels entièrement nouvelle, autrement vide. La fonction originale qui a déclenché cette chaîne a déjà disparu complètement.

    Le modèle Async/Await : Suspension des coroutines (Pause & Reprise)

    async/await est mis en œuvre sur la base des générateurs ECMAScript et de la mécanique des coroutines.

    async function getUserData(userId) {
      try {
        const user = await fetchUser(userId);
        const orders = await fetchOrders(user.orderId);
        return calculateTotal(orders);
      } catch (err) {
        handleError(err);
      }
    }
    

    Lorsque le moteur atteint une expression await, il ne supprime pas la fonction — il suspend simplement ce cadre d’encodage spécifique sur place.

    1. Le contrôle revient au thread principal, ce qui le libère pour effectuer d’autres tâches — comme redessiner l’interface utilisateur dans un navigateur ou gérer d’autres requêtes entrantes dans Node.js.
    2. Tout ce qui concerne l’état local de la fonction — ses variables, son champ d’application, sa position — reste intact en mémoire.
    3. Lorsque la Promise attendue est résolue, le moteur restitue la valeur résultante dans ce même contexte d’exécution et reprend l’exécution exactement là où il s’était arrêté.

    Cette seule distinction — soit transmettre un callback déconnecté à exécuter plus tard, soit geler puis réactiver un cadre d’exécution — explique presque toutes les différences pratiques que vous remarquerez entre ces deux approches.

    2. L’anatomie d’une pile d’appels : pourquoi le débogage diffère

    Pister les bugs en production est déjà l’un des aspects les plus difficiles du travail avec JavaScript, et la différence entre les chaînes de Promise et async/await apparaît immédiatement dès que l’on commence à lire les journaux d’erreur.

    À quoi ressemble un traceur de pile de Promise

    Prenons une chaîne de Promise où l’échec se produit à plusieurs niveaux profonds au sein d’une appel réseau :

    function stepOne() {
      return stepTwo();
    }
    
    function stepTwo() {
      return stepThree();
    }
    
    function stepThree() {
      return fetch('https://invalid-url-domain-does-not-exist.xyz')
        .then(res => res.json());
    }
    
    stepOne().catch(err => console.error(err.stack));
    

    Lorsqu’il y a une panne, le traceur résultant ressemble généralement à ceci :

    TypeError: Failed to fetch
        at stepThree (<anonymous>:10:5)
        at processTicksAndRejections (node:internal/process/task_queues:95:5)
    

    Qu’est-ce qui manque ? Ni stepOne ni stepTwo n’apparaissent nulle part dans ce trace.

    Lorsque l’appel réseau a réellement échoué, stepOne et stepTwo avaient déjà depuis longtemps terminé leur exécution et étaient sortis de la pile d’exécution. Le code de gestion des erreurs s’est exécuté lors d’un nouveau cycle de microtâche, complètement détaché du point d’origine de la chaîne d’appels. Si stepThree est une fonction utilitaire partagée appelée depuis cinquante endroits différents dans votre code, ce trace ne vous permet pas de déterminer quel appelant a provoqué cet échec en particulier.

    À quoi ressemble un trace Async/Await

    Réécrivez maintenant la même logique en utilisant async/await:

    async function stepOne() {
      return await stepTwo();
    }
    
    async function stepTwo() {
      return await stepThree();
    }
    
    async function stepThree() {
      const res = await fetch('https://invalid-url-domain-does-not-exist.xyz');
      return await res.json();
    }
    
    (async () => {
      try {
        await stepOne();
      } catch (err) {
        console.error(err.stack);
      }
    })();
    

    Voici le trace qu’il génère :

    TypeError: Failed to fetch
        at stepThree (<anonymous>:10:21)
        at async stepTwo (<anonymous>:6:10)
        at async stepOne (<anonymous>:2:10)
        at async <anonymous>:15:5
    

    Puisque le moteur suspend le cadre au lieu de le démanteler, les moteurs JavaScript modernes — tels que V8 dans Chrome et Node.js — sont capables de reconstruire l’historique des appels asynchrones sans coût de fonctionnement significatif. Le moteur suit la continuation à travers chaque point de suspension, de sorte que le suivi montre l’ensemble du chemin qui a mené à l’appel défaillant à l’intérieur de stepThree.

    3. Le piège du « Return Await » : un cas limite crucial

    Il existe un désaccord de longue date quant à savoir si return await promise est redondant par rapport à l’écriture simple de return promise.

    Hors d’un bloc try/catch, renvoyer directement la Promise vous fait gagner un cycle de microtâche supplémentaire, ce qui rend techniquement return promise plus efficace. Mais à l’intérieur d’un try/catch, ce seul mot — await — change complètement la manière dont les erreurs sont capturées.

    La version défectueuse

    async function fetchUserData(userId) {
      try {
        // BUG: Returning the promise directly without awaiting it
        return api.getUser(userId);
      } catch (error) {
        console.error("Failed to fetch user, applying fallback...", error);
        return getFallbackUser();
      }
    }
    

    Une demi-seconde plus tard, lorsque la Promise est effectivement rejetée, le bloc try/catch a déjà terminé son exécution. La clause catch ne s’exécute jamais, car elle n fait plus partie du chemin d’exécution actif. Au lieu de cela, le rejet échappe complètement à votre gestion locale des erreurs et apparaît comme un rejet non géré quelque part dans la chaîne d’appels, là où fetchUserData a été invoqué.

    async function fetchUserData(userId) {
      try {
        // CORRECT: Await forces resolution BEFORE exiting the try block
        return await api.getUser(userId);
      } catch (error) {
        console.error("Failed to fetch user, applying fallback...", error);
        return getFallbackUser();
      }
    }
    

    L’ajout de await résout ce problème car il indique au moteur de s’arrêter à cette ligne et de rester à l’intérieur du bloc try actuel jusqu’à ce que la Promise soit résolue. Si elle est rejetée, l’exécution reste dans ce contexte suffisamment longtemps pour que le bloc catch puisse la capturer, au lieu de permettre à la fonction de retourner avant même que l’erreur ne se produise.

    4. Portée et état partagé : Échapper aux « acrobaties de portée »

    Les chaînes de Promise imbriquées deviennent particulièrement complexes lorsque l’étape suivante a besoin des données produites par une étape antérieure.

    Imaginez que vous devez récupérer un utilisateur, puis son profil, et enfin générer une facture qui dépend des données issues des deux étapes :

    function generateReceipt(userId) {
      return getUser(userId)
        .then(user => {
          return getProfile(user.profileId)
            .then(profile => {
              // You are forced to nest here just to access `user` and `profile` together
              return createReceipt(user, profile);
            });
        });
    }
    

    Afin d’éviter cette forme en pyramide, les développeurs ont souvent recours à la déclaration de variables en dehors de la chaîne, afin que les callbacks .then() ultérieurs puissent y accéder :

    function generateReceipt(userId) {
      let tempUser; // Leaking scope variable
    
      return getUser(userId)
        .then(user => {
          tempUser = user;
          return getProfile(user.profileId);
        })
        .then(profile => {
          return createReceipt(tempUser, profile);
        });
    }
    

    async/await élimine la nécessité de cette solution de contournement, car chaque variable que vous déclarez reste dans le même scope au niveau de la fonction tout au long de la durée d’exécution de l’opération :

    async function generateReceipt(userId) {
      const user = await getUser(userId);
      const profile = await getProfile(user.profileId);
      return createReceipt(user, profile);
    }
    

    Il n’y a pas de variable externe à pré-déclarer, aucune structure de callback imbriquée, et pas besoin de remonter dans la chaîne pour déterminer d’où provient une valeur.

    5. Performance et concurrence : le piège séquentiel

    async/await rend le code plus lisible, mais il ouvre la porte à une erreur coûteuse : la sérialisation involontaire d’opérations qui pourraient s’exécuter en même temps.

    Les développeurs qui passent des chaînes de Promise à async/await finissent souvent par écrire quelque chose comme ceci sans se rendre compte du coût en termes de performance :

    // SLOW: 3 sequential network requests (Waterfall)
    async function loadDashboardData(userId) {
      const user = await fetchUser(userId);       // Takes 200ms
      const posts = await fetchPosts(userId);     // Takes 200ms (waits for user)
      const stats = await fetchStats(userId);     // Takes 200ms (waits for posts)
    
      return { user, posts, stats };              // Total time: 600ms
    }
    

    Comme chaque await bloque la fonction jusqu’à ce que l’appel soit résolu, fetchPosts ne commencera même pas avant que fetchUser n’ait complètement terminé — bien que fetchPosts et fetchStats n’aient absolument aucune dépendance par rapport au résultat de fetchUser.

    SEQUENTIAL (Slow Waterfall):
    [-- fetchUser (200ms) --] -> [-- fetchPosts (200ms) --] -> [-- fetchStats (200ms) --] = 600ms
    
    CONCURRENT (Optimized):
    [-- fetchUser (200ms) --]
    [-- fetchPosts (200ms) --]
    [-- fetchStats (200ms) --] = 200ms (Total)
    

    La solution : combiner async/await avec les combinateurs de Promise

    async/await n’est pas un substitut aux méthodes utilitaires de Promise — il est conçu pour être utilisé en conjonction avec elles.

    // FAST: All 3 requests start immediately in parallel
    async function loadDashboardData(userId) {
      const [user, posts, stats] = await Promise.all([
        fetchUser(userId),
        fetchPosts(userId),
        fetchStats(userId)
      ]);
    
      return { user, posts, stats }; // Total time: ~200ms
    }
    

    6. Boucles et itération : le piège des méthodes d’array

    Une source fréquente d’erreurs dans les bases de code JavaScript actuelles provient de la combinaison de callbacks asynchrones avec des méthodes d’array telles que .forEach(), .map() ou .filter().

    async function saveUsers(users) {
      users.forEach(async (user) => {
        await db.users.insert(user);
      });
    
      console.log("All users saved!"); // LIES: This logs BEFORE the database calls finish!
    }
    

    Cela échoue car Array.prototype.forEach est intrinsèquement synchrone — il ne prend jamais en compte la valeur retournée par la fonction de callback. Même si l’envoi d’une fonction asynchrone à forEach fait en sorte que chaque appel génère une Promise, forEach ne dispose d’aucun mécanisme pour attendre ces Promises ; il passe directement à l’itération suivante.

    Il existe deux alternatives correctes, en fonction de vos besoins réels.

    L’option A exécute le travail un élément à la fois à l’aide d’une boucle for...of, ce qui est approprié lorsque l’ordre compte ou si vous devez éviter de surcharger une API à débit limité :

    async function saveUsersSequentially(users) {
      for (const user of users) {
        await db.users.insert(user);
      }
      console.log("All users saved safely in order.");
    }
    

    L’option B exécute tout en parallèle en associant .map() à Promise.all, ce qui convient aux cas où les opérations sont indépendantes les unes des autres :

    async function saveUsersConcurrently(users) {
      await Promise.all(users.map(user => db.users.insert(user)));
      console.log("All users saved simultaneously.");
    }
    

    7. Lorsque les Promises brutes restent préférables

    async/await devrait être votre outil de prédilection pour la logique métier linéaire, mais il existe des situations architecturales spécifiques où travailler directement avec les Promises est le meilleur choix.

    La première concerne les tâches en arrière-plan à exécuter sans surveillance — des cas où vous souhaitez déclencher un effet secondaire sans faire attendre celui qui lance l’opération :

    async function handleUserSignup(req, res) {
      const user = await db.createUser(req.body);
    
      // Non-blocking: Fire and handle errors without awaiting
      sendWelcomeEmail(user.email).catch(err => {
        logger.error("Failed to send welcome email in background", err);
      });
    
      // Respond immediately without waiting for the email service
      return res.json({ status: "success", user });
    }
    

    La seconde concerne la gestion de scénarios de concurrence plus complexes, où les méthodes combinateurs dédiées aux Promises offrent un comportement conçu spécifiquement à cet effet :

    • Promise.race() vous permet d’interrompre rapidement les appels réseau lents grâce à une expiration temporelle.
    • Promise.allSettled() exécute plusieurs tâches indépendantes et rapporte leur résultat, qu’il s’agisse de succès ou d’échecs.
    • Promise.any() se résout dès que l’une des Promises fournies réussit, ce qui est utile pour mettre en place des stratégies de secours dans différentes régions ou sources.
    // Timeout a network request after 5 seconds
    const data = await Promise.race([
      fetch('https://api.example.com/data').then(r => r.json()),
      new Promise((_, reject) =>
        setTimeout(() => reject(new Error('Request timed out')), 5000)
      )
    ]);
    

    Le troisième scénario consiste à créer un pont autour d’API legacy basées sur des appels de retour — par exemple fs.readFile de Node ou un SDK plus ancien — qui exigent la construction explicite d’un new Promise():

    function readConfigFile(path) {
      return new Promise((resolve, reject) => {
        legacyLib.readFile(path, (err, data) => {
          if (err) return reject(err);
          resolve(data);
        });
      });
    }
    

    8. Comparaison synthétique

    Le verdict

    async/await n’est pas simplement une syntaxe esthétique superposée aux Promises.

    Même s’il repose toujours sur les Promises en arrière-plan pour représenter l’achèvement des tâches, il transforme le code asynchrone en passant d’une chaîne d’enregistrements de callbacks à quelque chose qui ressemble davantage à une coroutine interrompable. C’est ce changement qui permet d’obtenir des traces d’exécution utiles, un traitement des erreurs au niveau du langage via try/catch, ainsi qu’un encadrement lexical simple.

    Pour s’habituer au JavaScript asynchrone moderne, il suffit finalement de savoir quand le style linéaire et lisible de async/await convient à sa logique séquentielle, et quand il faut recourir aux combinateurs de Promise bruts — Promise.all, Promise.allSettled, Promise.race — pour gérer efficacement la concurrence.