📍 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()enasync/awaitlinéaire. - Lancer plusieurs opérations en parallèle avec
Promise.alletPromise.allSettled. - Capturer les erreurs asynchrones avec
try/catchet 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,3dans cet ordre. Si vous voyez3avant2, 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()(avecconsole.time/console.timeEnd). Il doit avoisiner 400 ms, pas 700. Si vous obtenez 700, c’est que vous avez écrit deuxawaitséquentiels au lieu d’unPromise.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
- Fonctions, portée et closures — le tutoriel précédent.
- Manipuler le DOM et consommer une API avec fetch — où l’on remplace les simulations par de vrais appels
fetch.
Pour aller plus loin
- 🔝 Retour au guide : JavaScript moderne : le guide complet
- MDN — Utiliser les promesses
- Tutoriel suivant : Modules ES : organiser son code
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.