Développement Web

Asynchrone en JavaScript : promesses et async/await

13 min de lecture

📍 Le guide du parcours : JavaScript moderne : le guide complet

Troisième tutoriel de la série JavaScript moderne. Il s’appuie sur les fonctions et closures vues précédemment.

Introduction

Votre application doit charger les tâches depuis un serveur. Cette opération prend du temps — quelques dizaines de millisecondes, parfois plusieurs secondes sur une connexion lente. Pendant ce temps, l’interface ne doit pas se figer. Comment un langage à fil d’exécution unique gère-t-il cela sans bloquer ? La réponse est l’asynchrone, et ses outils modernes : les promesses et async/await. À la fin de ce tutoriel, vous saurez transformer une cascade de rappels illisible en code linéaire, attendre plusieurs opérations en parallèle, et surtout gérer les erreurs proprement — la partie que tout le monde néglige et qui fait pourtant la différence.

🎯 Ce que vous allez apprendre

  • Expliquer la boucle d’événements et pourquoi JavaScript ne bloque pas malgré son fil unique.
  • Lire et écrire une promesse, et connaître ses trois états.
  • Convertir du code à base de .then() en async/await linéaire.
  • Lancer plusieurs opérations en parallèle avec Promise.all et Promise.allSettled.
  • Capturer les erreurs asynchrones avec try/catch et ne plus jamais en avaler une silencieusement.

🛠️ Ce que vous allez construire

Vous allez écrire la couche d’accès aux données de CarnetTâches : une fonction chargerTaches() qui récupère les tâches via le réseau, une enregistrerTache() qui en ajoute une, et un chargement initial qui va chercher en parallèle les tâches et la liste des étiquettes. Le tout avec une gestion d’erreurs robuste qui distingue « le serveur a répondu une erreur » de « le réseau est tombé ».

Prérequis

  • Un navigateur récent et sa console (F12). Aucune installation.
  • Maîtriser les fonctions et les fonctions fléchées. Test express : si vous savez écrire arr.map(x => x * 2), vous êtes prêt ; sinon, voyez Fonctions, portée et closures.
  • ⏱️ Temps estimé : ~45 minutes.

Étape 1 — Pourquoi l’asynchrone existe

Le moteur JavaScript exécute une seule chose à la fois sur un unique fil. Si une opération longue était synchrone, tout le reste attendrait : l’utilisateur ne pourrait plus cliquer, l’animation se figerait. La solution est la boucle d’événements. Une opération longue (appel réseau, minuterie) est déléguée à l’environnement, le moteur continue d’exécuter le reste du code, et le résultat est traité plus tard, quand la pile d’exécution est libre.

console.log("1 — début");

setTimeout(() => {
  console.log("3 — différé (même avec un délai de 0 !)");
}, 0);

console.log("2 — fin du code synchrone");

// Ordre d'affichage : 1, puis 2, puis 3
// Le code synchrone s'exécute entièrement AVANT le rappel différé.

Même avec un délai de zéro, le rappel de setTimeout s’exécute après tout le code synchrone : il est placé dans une file d’attente que la boucle d’événements ne traite qu’une fois la pile vide. Cette mécanique explique tout l’asynchrone. Notez aussi qu’il existe deux files : les microtâches (promesses) sont traitées en priorité, avant les macrotâches (minuteries, événements). C’est pourquoi une promesse résolue se déclenche avant un setTimeout(…, 0).

Point d’étape — Copiez ce bloc dans la console. Vous devez voir 1, 2, 3 dans cet ordre. Si vous voyez 3 avant 2, relisez : c’est précisément ce que la boucle d’événements empêche.

Étape 2 — Anatomie d’une promesse

Une promesse est un objet qui représente le résultat futur d’une opération asynchrone. Elle a trois états : en attente (pending) au départ, puis soit tenue (fulfilled) avec une valeur, soit rompue (rejected) avec une erreur. Une fois sortie de l’attente, son état est figé. On réagit au résultat avec .then() pour le succès et .catch() pour l’échec.

// Une promesse qui simule un chargement réseau réussi après 400 ms
function chargerTachesSimule() {
  return new Promise((resoudre, rejeter) => {
    setTimeout(() => {
      const ok = true;
      if (ok) {
        resoudre([{ id: 1, titre: "Réviser", fait: false }]);
      } else {
        rejeter(new Error("Serveur indisponible"));
      }
    }, 400);
  });
}

chargerTachesSimule()
  .then(taches => console.log("Reçu :", taches.length, "tâche(s)"))
  .catch(erreur => console.error("Échec :", erreur.message))
  .finally(() => console.log("Chargement terminé"));

Le constructeur Promise reçoit une fonction avec deux rappels : resoudre pour signaler le succès, rejeter pour l’échec. Dans la vraie vie, vous créerez rarement des promesses à la main — les API modernes comme fetch vous en renvoient déjà. .then() reçoit la valeur tenue, .catch() capture toute erreur survenue en amont, et .finally() s’exécute dans les deux cas, idéal pour masquer un indicateur de chargement.

Étape 3 — async/await, l’asynchrone qui se lit comme du synchrone

Chaîner des .then() devient vite illisible dès qu’une opération en dépend d’une autre. async/await résout cela : une fonction marquée async peut utiliser await pour « attendre » une promesse, comme si le code était séquentiel — sans pour autant bloquer le fil. C’est du sucre syntaxique au-dessus des promesses, mais il change radicalement la lisibilité.

async function afficherNombreDeTaches() {
  const taches = await chargerTachesSimule();   // attend la valeur tenue
  console.log("Il y a", taches.length, "tâche(s)");
  return taches.length;                          // une fonction async renvoie TOUJOURS une promesse
}

// Appel : la fonction renvoie une promesse, qu'on consomme avec await ou .then()
afficherNombreDeTaches().then(n => console.log("Total renvoyé :", n));

Deux points essentiels. D’abord, await ne fonctionne qu’à l’intérieur d’une fonction async (ou au niveau supérieur d’un module ES, voir le tutoriel sur les modules). Ensuite, une fonction async renvoie toujours une promesse, même si vous renvoyez une valeur simple : c’est pourquoi on consomme son résultat avec await ou .then(). Le code se lit de haut en bas, ligne après ligne, alors qu’il reste totalement non bloquant.

Point d’étape — Votre console doit afficher « Il y a 1 tâche(s) » puis « Total renvoyé : 1 », dans cet ordre, environ 400 ms après l’appel. Si « Total renvoyé » apparaît avant le décompte, c’est que vous avez oublié le await à l’intérieur de la fonction.

Étape 4 — Gérer les erreurs sans en avaler aucune

C’est la partie qui sépare le code de démonstration du code de production. Avec async/await, on capture les erreurs avec un try/catch classique, exactement comme en synchrone. Une promesse rejetée sans gestion devient une « rejection non gérée » qui passe souvent inaperçue jusqu’à ce qu’un bug surgisse en production.

async function chargerTaches() {
  try {
    const taches = await chargerTachesSimule();
    return taches;
  } catch (erreur) {
    // On journalise et on renvoie une valeur de repli plutôt que de planter l'appli
    console.error("Impossible de charger les tâches :", erreur.message);
    return [];                       // repli : liste vide, l'interface reste utilisable
  }
}

const taches = await chargerTaches();   // au niveau supérieur d'un module ES
console.log("Affichage de", taches.length, "tâche(s)");

Le try/catch entoure l’await : si la promesse est rompue, l’exécution saute directement dans le catch, où l’on décide quoi faire. Ici, on choisit un repli (liste vide) pour que l’application reste utilisable même si le serveur est injoignable. La règle d’or : toute opération asynchrone qui peut échouer doit avoir un endroit où son erreur est traitée. Ne jamais lancer un await « nu » sans try/catch ni .catch() en amont.

Étape 5 — Plusieurs opérations en parallèle

Souvent, on a besoin de plusieurs ressources indépendantes — par exemple, charger les tâches et la liste des étiquettes au démarrage. Les attendre l’une après l’autre serait deux fois plus lent. Promise.all lance toutes les promesses en même temps et attend qu’elles soient toutes tenues, renvoyant un tableau de résultats dans l’ordre.

function chargerEtiquettesSimule() {
  return new Promise(r => setTimeout(() => r(["étude", "lecture", "urgent"]), 300));
}

async function initialiser() {
  try {
    // Les deux chargements démarrent EN MÊME TEMPS, pas l'un après l'autre
    const [taches, etiquettes] = await Promise.all([
      chargerTachesSimule(),
      chargerEtiquettesSimule()
    ]);
    console.log(taches.length, "tâches,", etiquettes.length, "étiquettes");
    return { taches, etiquettes };
  } catch (erreur) {
    console.error("Échec de l'initialisation :", erreur.message);
    return { taches: [], etiquettes: [] };
  }
}

await initialiser();   // terminé en ~400 ms (le plus lent), pas 700 ms

Le gain est réel : le temps total est celui de la plus lente des promesses, pas leur somme. Attention toutefois : Promise.all rejette dès qu’une seule promesse échoue, abandonnant les autres résultats. Si vous voulez au contraire le sort de chacune, qu’elle réussisse ou non, utilisez Promise.allSettled, qui renvoie pour chaque promesse un objet { status, value } ou { status, reason }.

const resultats = await Promise.allSettled([
  chargerTachesSimule(),
  Promise.reject(new Error("étiquettes indisponibles"))
]);
console.log(resultats[0].status);   // "fulfilled"
console.log(resultats[1].status);   // "rejected" — mais resultats[0] est bien là

Point d’étape — Mesurez le temps de initialiser() (avec console.time/console.timeEnd). Il doit avoisiner 400 ms, pas 700. Si vous obtenez 700, c’est que vous avez écrit deux await séquentiels au lieu d’un Promise.all.

Étape 6 — Enregistrer une tâche

Le pendant du chargement, c’est l’écriture. enregistrerTache() simule l’envoi d’une nouvelle tâche au serveur et renvoie la tâche enregistrée (avec, dans la vraie vie, l’identifiant attribué par le serveur). On y illustre la propagation d’erreur : ici, on relance l’erreur pour que l’appelant décide, au lieu de la masquer.

function envoyerAuServeurSimule(tache) {
  return new Promise((resoudre, rejeter) => {
    setTimeout(() => {
      if (!tache.titre) rejeter(new Error("Titre obligatoire"));
      else resoudre({ ...tache, id: Math.floor(Math.random() * 1000) + 10 });
    }, 250);
  });
}

async function enregistrerTache(tache) {
  try {
    const enregistree = await envoyerAuServeurSimule(tache);
    console.log("Tâche enregistrée, id =", enregistree.id);
    return enregistree;
  } catch (erreur) {
    console.error("Enregistrement refusé :", erreur.message);
    throw erreur;            // on relance : l'appelant gère (afficher un message, par ex.)
  }
}

try {
  await enregistrerTache({ titre: "Préparer la démo", fait: false });
  await enregistrerTache({ titre: "", fait: false });   // sera rejetée
} catch (e) {
  console.log("L'appelant a vu l'échec et peut réagir.");
}

Le choix entre « avaler » une erreur (renvoyer un repli) et la « relancer » (avec throw) dépend du contexte. Au chargement, un repli garde l’interface vivante. À l’enregistrement, relancer permet d’avertir l’utilisateur que sa tâche n’a pas été sauvegardée — une information qu’on ne doit surtout pas masquer.

Étape 7 — Vérification finale

Mettons bout à bout l’initialisation, l’ajout et le rechargement, comme le ferait l’application réelle au démarrage.

async function scenarioComplet() {
  const { taches } = await initialiser();
  console.log("Au départ :", taches.length, "tâche(s)");

  const nouvelle = await enregistrerTache({ titre: "Tester l'asynchrone", fait: false });
  const misesAJour = [...taches, nouvelle];
  console.log("Après ajout :", misesAJour.length, "tâche(s)");
  return misesAJour;
}

const etat = await scenarioComplet();
console.log("État final :", etat.map(t => t.titre));

Vous devez voir le décompte initial, puis un décompte augmenté d’une unité, puis la liste des titres. Tout s’enchaîne de manière lisible, chaque étape attend la précédente quand il le faut, et aucune erreur ne peut filer sans être vue. La couche d’accès aux données de CarnetTâches est opérationnelle.

🐞 Pièges fréquents

Symptôme / erreur Cause probable Correctif
SyntaxError: await is only valid in async functions await hors d’une fonction async (et hors module) Marquer la fonction async, ou utiliser le module ES
La fonction renvoie Promise { <pending> } On a oublié d’attendre le résultat Ajouter await ou enchaîner .then()
UnhandledPromiseRejection Une promesse rejetée sans catch Entourer l’await d’un try/catch
Deux chargements indépendants sont lents await séquentiels au lieu de parallèle Regrouper avec Promise.all
Promise.all perd tous les résultats sur un échec Il rejette dès la première promesse rompue Utiliser Promise.allSettled si l’on veut tous les résultats

🌍 Réalités du terrain

L’asynchrone n’est pas qu’un confort d’écriture : sur une connexion lente ou instable, c’est ce qui maintient une application réactive pendant qu’une requête patiente. Deux réflexes paient particulièrement. D’abord, paralléliser les requêtes indépendantes avec Promise.all divise le temps d’attente ressenti — précieux quand chaque aller-retour coûte cher. Ensuite, toujours prévoir un repli en cas d’échec réseau (liste vide, données en cache) : une application qui plante au premier paquet perdu est inutilisable là où la connexion vacille. Vous verrez au tutoriel suivant comment annuler une requête devenue inutile avec AbortController, autre économie directe de bande passante.

✅ Récapitulatif

Vous comprenez maintenant pourquoi un langage à fil unique ne bloque pas, grâce à la boucle d’événements et ses files de tâches. Vous lisez une promesse et ses trois états, et vous écrivez du code asynchrone linéaire avec async/await. Vous capturez les erreurs avec try/catch et vous choisissez sciemment entre repli et relance. Enfin, vous parallélisez avec Promise.all et Promise.allSettled. La couche de données de CarnetTâches charge, enregistre et gère ses erreurs proprement.

🧾 Aide-mémoire

Élément Rôle
new Promise((res, rej) => ...) Crée une promesse ; res = succès, rej = échec
async function Autorise await ; renvoie toujours une promesse
await p Attend la valeur tenue de p sans bloquer le fil
try { await p } catch (e) Capture une promesse rompue
Promise.all([...]) Toutes en parallèle ; rejette à la première erreur
Promise.allSettled([...]) Toutes en parallèle ; renvoie le sort de chacune
.finally(fn) S’exécute dans tous les cas (succès comme échec)

💪 À vous de jouer

Écrivez une fonction chargerAvecDelaiMax(promesse, ms) qui renvoie le résultat de la promesse, mais rejette avec une erreur « délai dépassé » si elle met plus de ms millisecondes. Indice : Promise.race tient la première promesse résolue ou rompue.

Voir une solution
function chargerAvecDelaiMax(promesse, ms) {
  const expiration = new Promise((_, rejeter) =>
    setTimeout(() => rejeter(new Error("Délai dépassé")), ms)
  );
  // race : la première des deux à se résoudre/rompre l'emporte
  return Promise.race([promesse, expiration]);
}

try {
  const taches = await chargerAvecDelaiMax(chargerTachesSimule(), 1000); // ok (400 ms)
  console.log(taches.length);
  await chargerAvecDelaiMax(chargerTachesSimule(), 100);  // rejette : trop lent
} catch (e) {
  console.error(e.message);   // "Délai dépassé"
}

Tutoriels liés

Pour aller plus loin

FAQ

Quelle différence entre une microtâche et une macrotâche ?
Les microtâches (rappels de promesses) sont traitées en priorité, dès que la pile d’exécution se vide, avant les macrotâches (minuteries, événements). C’est pourquoi un .then() se déclenche avant un setTimeout(…, 0) programmé au même moment.

Dois-je toujours utiliser async/await plutôt que .then() ?
Pour des enchaînements séquentiels, async/await est plus lisible. .then() reste pratique pour un traitement ponctuel sans fonction async alentour, ou pour composer des chaînes courtes. Les deux sont interopérables.

Que se passe-t-il si j’oublie await ?
Vous manipulez l’objet promesse au lieu de sa valeur résolue. Les comparaisons et accès échouent silencieusement, et une éventuelle erreur n’est pas capturée. C’est l’un des bugs asynchrones les plus courants.

Promise.all ou Promise.allSettled ?
Promise.all si l’échec d’une seule ressource doit tout interrompre (toutes sont indispensables). Promise.allSettled si vous voulez le résultat de chacune indépendamment, par exemple pour afficher ce qui a réussi malgré une panne partielle.

Partager