Chapitre 1025 minDébutant
JavaScript asynchrone : promesses, async/await et fetch
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- expliquer pourquoi JavaScript est asynchrone et ce que fait la boucle d'événements ;
- lire et écrire une promesse avec
then,catchetfinally; - écrire une fonction
asyncqui attend avecawaitet gère les erreurs ; - appeler une API avec
fetch, vérifierresponse.oket fixer un délai maximal ; - lancer plusieurs requêtes en parallèle avec
Promise.all.
4 exercices corrigés2 schémas
Le code asynchrone lance une opération longue (une requête réseau, une minuterie) sans bloquer la page, et traite son résultat plus tard, quand il arrive. En JavaScript, ce résultat futur est représenté par une promesse (Promise), que les mots-clés async et await permettent d'attendre avec un code qui se lit de haut en bas. Ce chapitre explique pourquoi le navigateur fonctionne ainsi, comment lire et écrire des promesses, puis comment appeler une API avec fetch en traitant chaque cas d'erreur. Le protocole HTTP lui-même (méthodes, en-têtes, codes de statut) est expliqué dans le cours API REST.
Pourquoi JavaScript est asynchrone
JavaScript exécute ton code sur un seul fil (thread) : une seule instruction à la fois. Ce même fil gère aussi les clics et l'affichage de la page. Si ton code attendait sans rien faire la réponse d'un serveur, qui met entre quelques dizaines de millisecondes et plusieurs secondes à arriver, la page serait figée pendant tout ce temps : boutons inertes, défilement bloqué.
// À NE PAS FAIRE : une attente qui bloque tout pendant 3 secondes
const fin = Date.now() + 3000;
while (Date.now() < fin) {}
console.log('après');
La solution : confier l'attente au navigateur, continuer, et être prévenu quand le résultat est prêt. C'est ce que font setTimeout, addEventListener et fetch.
console.log('1. début');
setTimeout(() => console.log('3. une seconde plus tard'), 1000);
console.log('2. fin du script');
1. début
2. fin du script
3. une seconde plus tard
La boucle d'événements
Comment le navigateur organise-t-il tout cela ? Ton code s'exécute dans la pile d'appels. Ce qui attend (minuteries, requêtes, écouteurs) est confié aux API du navigateur. Quand une attente se termine, la suite à exécuter est rangée dans une file. La boucle d'événements (event loop) attend que la pile soit vide, puis y place la suite de la file.
Il y a deux files, et l'ordre compte : les suites des promesses (microtâches) passent avant les rappels de minuteries et d'événements (tâches). D'où ce résultat, qui surprend toujours la première fois :
console.log('1. début');
setTimeout(() => console.log('4. setTimeout 0 ms'), 0);
Promise.resolve().then(() => console.log('3. promesse résolue'));
console.log('2. fin du script');
1. début
2. fin du script
3. promesse résolue
4. setTimeout 0 ms
Le script entier s'exécute d'abord (1 et 2) ; ensuite la file des microtâches est vidée (3) ; enfin la tâche de la minuterie passe (4), même avec un délai de 0 ms. Retiens surtout que rien d'asynchrone ne s'exécute tant que ton code synchrone n'est pas terminé.
Les callbacks, l'ancienne méthode
Avant les promesses, on passait une fonction à appeler une fois le travail fini (un callback, chapitre 4). Dès que plusieurs étapes dépendent l'une de l'autre, les callbacks s'imbriquent :
chargerFournisseur(1, (fournisseur) => {
chargerCommandes(fournisseur.id, (commandes) => {
chargerLignes(commandes[0].id, (lignes) => {
console.log(lignes); // trois niveaux, et aucune gestion d'erreur
});
});
});
Cette pyramide, surnommée callback hell, est difficile à lire et chaque niveau doit gérer ses erreurs. Tu la croiseras dans du code ancien ; les promesses ont été inventées pour l'aplatir.
Les promesses : then, catch, finally
Une promesse est un objet qui représente le résultat d'une opération pas encore terminée. Elle est dans l'un de trois états :
| État | Sens | Ce qui s'exécute |
|---|---|---|
| En attente (pending) | L'opération est en cours | Rien pour l'instant |
| Tenue (fulfilled) | L'opération a réussi, avec une valeur | Les fonctions passées à then |
| Rompue (rejected) | L'opération a échoué, avec une erreur | Les fonctions passées à catch |
Une promesse ne change d'état qu'une fois : tenue ou rompue, c'est définitif. Pour comprendre le mécanisme, voici une fonction qui simule une vérification de stock sur un serveur lent :
function verifierStock(ref) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (ref === 'SSD-512') resolve({ ref, quantite: 12 });
else reject(new Error(`Référence ${ref} inconnue`));
}, 100);
});
}
const p = verifierStock('SSD-512');
console.log(p); // Promise { <pending> } : le résultat n'est pas encore là
p.then((piece) => console.log('Reçu', piece.quantite))
.catch((erreur) => console.log('Erreur', erreur.message))
.finally(() => console.log('Terminé'));
// Reçu 12
// Terminé
then(fonction)s'exécute si la promesse est tenue, avec sa valeur.catch(fonction)s'exécute si elle est rompue, ou si une erreur se produit dans unthenprécédent.finally(fonction)s'exécute dans les deux cas : idéal pour masquer un indicateur de chargement.
then renvoie lui-même une promesse, d'où la possibilité d'enchaîner. Tu écriras rarement new Promise : les API modernes (fetch, les fonctions de fichiers de Node.js) renvoient déjà des promesses.
async et await : attendre sans bloquer
await met en pause la fonction en cours jusqu'à ce que la promesse soit réglée, puis donne sa valeur ; pendant ce temps, le reste de la page continue de fonctionner. await ne s'utilise que dans une fonction déclarée async (ou au premier niveau d'un module). Le même code qu'au-dessus, réécrit :
async function afficherStock(ref) {
try {
const piece = await verifierStock(ref);
console.log(`${piece.ref} : ${piece.quantite}`);
} catch (erreur) {
console.log(`Échec : ${erreur.message}`);
} finally {
console.log('Vérification terminée');
}
}
await afficherStock('SSD-512'); // SSD-512 : 12, puis Vérification terminée
await afficherStock('ABC-12'); // Échec : Référence ABC-12 inconnue, puis Vérification terminée
Une promesse rompue devient, avec await, une exception qu'attrape un try…catch ordinaire : la gestion d'erreur est la même que pour du code synchrone (chapitre 12).
Deux règles à retenir :
- Une fonction
asyncrenvoie toujours une promesse, même si elle faitreturn 42. Celui qui l'appelle doit donc l'attendre à son tour avecawait(outhen). - Oublier
awaitne provoque pas d'erreur : tu obtiens la promesse au lieu de la valeur.
const pieces = chargerPiecesApi('/api/pieces'); // await oublié
console.log(pieces.length); // undefined : pieces est une Promise, pas un tableau
fetch : faire une requête HTTP
fetch(url, options) envoie une requête HTTP et renvoie une promesse de réponse. Elle existe dans tous les navigateurs et dans Node.js depuis la version 18. Le cas le plus simple, une requête GET qui lit du JSON :
async function chargerPiecesApi(url) {
const reponse = await fetch(url, {
headers: { Accept: 'application/json' },
});
if (!reponse.ok) {
throw new Error(`Le serveur a répondu ${reponse.status}.`);
}
return reponse.json();
}
Il y a deux await dans un appel complet : le premier attend l'arrivée de la réponse (le statut et les en-têtes), le second, caché ici dans return reponse.json(), attend la lecture complète du corps et sa conversion en objet. L'objet Response fournit :
| Propriété ou méthode | Contenu |
|---|---|
reponse.status | Le code de statut : 200, 201, 404, 500… (codes HTTP) |
reponse.ok | true si le statut est entre 200 et 299 |
reponse.headers.get('content-type') | Un en-tête de la réponse |
await reponse.json() | Le corps lu comme JSON (lève une SyntaxError si ce n'en est pas) |
await reponse.text() | Le corps lu comme texte |
Le corps ne peut être lu qu'une fois : appeler json() puis text() sur la même réponse provoque une erreur.
Gérer les erreurs de fetch
C'est le point que la plupart des tutoriels oublient, et la source de bugs la plus fréquente : fetch ne rejette sa promesse que s'il n'a reçu aucune réponse. Une réponse 404 ou 500 est une réponse réussie du point de vue de fetch. Il y a donc trois issues, à traiter différemment :
| Situation | Ce que fait fetch | Ce que tu dois écrire |
|---|---|---|
| Réseau coupé, serveur arrêté, nom de domaine inconnu | Rejette avec TypeError (Chrome : Failed to fetch) | try…catch autour du await fetch |
| Requête bloquée par CORS | Rejette avec TypeError ; le détail n'est que dans la console | Corriger la configuration CORS du serveur (CORS) |
| Délai dépassé (si tu en as fixé un) | Rejette avec TimeoutError | signal: AbortSignal.timeout(ms) |
Réponse 4xx ou 5xx | Tient sa promesse, avec ok à false | if (!reponse.ok) throw … |
| Réponse reçue, mais le corps n'est pas du JSON (page d'erreur HTML) | reponse.json() rejette avec SyntaxError: Unexpected token '<'… | Vérifier ok avant de lire le corps |
Réponse 204 No Content | Corps vide | Ne pas appeler json() |
Sans délai maximal, une requête vers un serveur qui ne répond plus peut rester en attente très longtemps. AbortSignal.timeout(ms) annule la requête au bout du délai choisi. Voici la version complète de chargerPiecesApi, dans le module api.js de Gestock, qui distingue les deux familles d'échec dans ses messages :
export async function chargerPiecesApi(url, delaiMs = 5000) {
let reponse;
try {
reponse = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(delaiMs),
});
} catch (erreur) {
// Pas de réponse du tout : réseau coupé, serveur injoignable, délai dépassé.
throw new Error(`Serveur injoignable (${erreur.name}).`, { cause: erreur });
}
if (!reponse.ok) {
// Une réponse est arrivée, mais avec un code d'erreur (404, 500…).
throw new Error(`Le serveur a répondu ${reponse.status}.`);
}
return reponse.json();
}
L'option cause garde l'erreur d'origine attachée à la nouvelle, pour le débogage. Celui qui appelle chargerPiecesApi n'a plus qu'un seul try…catch à écrire, et reçoit un message compréhensible.
Pour annuler une requête toi-même (l'utilisateur tape une nouvelle recherche avant la fin de la précédente), crée un AbortController et passe son signal : controleur.abort() fait rejeter le fetch avec une erreur AbortError.
Envoyer des données : POST en JSON
Pour créer une ressource, on envoie une requête POST avec un corps JSON. Trois options s'ajoutent : la méthode, l'en-tête Content-Type qui annonce le format du corps, et le corps lui-même, converti en texte.
async function creerPieceApi(piece) {
const reponse = await fetch('/api/pieces', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(piece),
signal: AbortSignal.timeout(5000),
});
if (!reponse.ok) {
throw new Error(`Création refusée : ${reponse.status}`);
}
return reponse.json(); // une API REST renvoie en général 201 et la pièce créée
}
Testé contre une petite API de démonstration : le premier envoi de { ref: 'VEN-12', … } renvoie 201 et la pièce ; le second, avec la même référence, renvoie 409 Conflict, et la fonction lève Création refusée : 409. Une API bien conçue renvoie aussi un corps qui explique l'erreur (application/problem+json) : lis-le pour afficher un message précis (concevoir une API REST). PUT, PATCH et DELETE s'écrivent de la même façon ; une suppression réussie répond souvent 204, sans corps à lire.
Plusieurs requêtes : séquence ou parallèle
Deux await à la suite attendent l'un après l'autre. Quand les requêtes sont indépendantes, lance-les ensemble avec Promise.all, qui attend qu'elles soient toutes tenues :
// En séquence : la durée totale est la somme des deux
const pieces = await chargerPiecesApi('/api/pieces');
const fournisseurs = await chargerFournisseurs();
// En parallèle : la durée totale est celle de la plus lente
const [pieces2, fournisseurs2] = await Promise.all([chargerPiecesApi('/api/pieces'), chargerFournisseurs()]);
Promise.all rejette dès qu'une promesse rejette, et tu perds les autres résultats. Si chaque résultat compte séparément (un tableau de bord avec plusieurs blocs indépendants), utilise Promise.allSettled, qui attend tout et décrit chaque issue :
const resultats = await Promise.allSettled([chargerPiecesApi('/api/pieces'), chargerStatistiques()]);
resultats.map((r) => r.status); // par exemple ['fulfilled', 'rejected']
// r.value contient la valeur d'une promesse tenue, r.reason l'erreur d'une promesse rompue
Montrer à l'utilisateur ce qui se passe
Une requête a quatre états visibles, et l'interface doit afficher chacun : chargement en cours, erreur avec un message utile, liste vide, et données affichées. Un écran qui reste blanc pendant deux secondes, ou qui n'affiche rien après une erreur, laisse l'utilisateur sans savoir quoi faire. Ouvre l'onglet Réseau des outils de développement et choisis un débit lent (« 3G ») ou « Hors connexion » pour voir chacun de ces états (chapitre 12).
Fil rouge : Gestock charge son stock depuis une API
Au démarrage, Gestock relit le stock enregistré dans le navigateur avec chargerPieces (le module stockage.js du chapitre 9) ; s'il n'y en a pas (première visite), il le demande au serveur avec chargerPiecesApi. Pour l'instant, le « serveur » est un simple fichier public/data/pieces.json : Vite sert le dossier public/ à la racine du site et le copie tel quel dans dist/ au npm run build, donc fetch('data/pieces.json') marche en développement comme en production, et le lit exactement comme une API. Quand l'API Node.js existera, seule l'adresse changera.
async function demarrer() {
const enregistrees = chargerPieces(null);
if (enregistrees !== null) {
etat.pieces = enregistrees;
rafraichir();
return;
}
resume.textContent = 'Chargement du stock…';
try {
modifierStock(await chargerPiecesApi('data/pieces.json'));
} catch (erreur) {
resume.textContent = `Impossible de charger le stock : ${erreur.message}`;
}
}
demarrer();
Le paragraphe #resume affiche « Chargement du stock… » pendant la requête, puis le résumé du stock, ou un message d'erreur si le fichier est introuvable (« Le serveur a répondu 404. ») ou le serveur arrêté (« Serveur injoignable (TypeError). »).
Exercices
Exercice 1 sur 4 L'ordre d'affichage Réussi À revoir
Dans quel ordre s'affichent les lettres ?
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
(async () => {
console.log('D');
await null;
console.log('E');
})();
console.log('F');
Voir la réponseMasquer la réponse de l'exercice 1
A, D, F, C, E, B.
Le code synchrone d'abord : A, puis la fonction async s'exécute jusqu'à son premier await et affiche D, puis F. La pile est vide : les microtâches passent dans l'ordre où elles ont été créées, C (le then) puis E (la suite après await). Enfin la tâche de setTimeout : B.
Tu avais trouvé ?
Exercice 2 sur 4 Le bug du 404 Réussi À revoir
Ce code a deux défauts. Avec une adresse fausse (/api/piece au lieu de /api/pieces), il affiche « Le serveur est injoignable » alors que le serveur fonctionne. Et quand le serveur répond 500 avec un corps JSON {"erreur": "Base indisponible"}, il affiche « Stock chargé » et renvoie cet objet comme si c'était le stock. Explique les deux comportements et corrige.
async function charger() {
try {
const reponse = await fetch('/api/piece');
const pieces = await reponse.json();
console.log('Stock chargé');
return pieces;
} catch (e) {
console.log('Le serveur est injoignable');
}
}
Voir la réponseMasquer la réponse de l'exercice 2
Avec une adresse fausse, le serveur répond 404 avec une page HTML. fetch tient sa promesse, puisqu'une réponse est arrivée, et reponse.json() essaie de lire du HTML comme du JSON : SyntaxError: Unexpected token '<'. Le catch attrape cette erreur et affiche un message faux : le serveur a bien répondu.
Avec la réponse 500, le corps est du JSON valide : json() réussit, rien ne lève d'erreur, et l'objet d'erreur est renvoyé comme s'il s'agissait du stock. Le code planterait plus loin, sur un pieces.map is not a function sans rapport apparent.
Les deux défauts ont la même cause : le statut n'est jamais testé. La correction sépare l'absence de réponse du code d'erreur :
async function charger() {
let reponse;
try {
reponse = await fetch('/api/pieces', { signal: AbortSignal.timeout(5000) });
} catch (e) {
throw new Error('Le serveur est injoignable.', { cause: e });
}
if (!reponse.ok) {
throw new Error(`Le serveur a répondu ${reponse.status}.`);
}
return reponse.json();
}
Tu avais trouvé ?
Exercice 3 sur 4 Enregistrer une sortie de stock Réussi À revoir
L'API de Réparinfo accepte POST /api/sorties avec un corps JSON { "ref": "SSD-512", "quantite": 2, "technicien": "Karim" } et répond 201 avec la sortie créée, ou 422 avec { "erreurs": { "quantite": "Stock insuffisant" } }. Écris la fonction enregistrerSortie(sortie) qui renvoie la sortie créée, et qui, en cas de 422, lève une erreur dont le message est celui de l'API pour le premier champ en erreur.
Voir la réponseMasquer la réponse de l'exercice 3
async function enregistrerSortie(sortie) {
const reponse = await fetch('/api/sorties', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(sortie),
signal: AbortSignal.timeout(5000),
});
if (reponse.status === 422) {
const { erreurs } = await reponse.json();
const [premierMessage] = Object.values(erreurs);
throw new Error(premierMessage);
}
if (!reponse.ok) {
throw new Error(`Le serveur a répondu ${reponse.status}.`);
}
return reponse.json();
}
Le cas 422 est traité avant le test général de ok, parce que son corps contient une information utile à l'utilisateur. Les erreurs réseau (pas de réponse) remontent telles quelles à l'appelant, qui les attrape dans son propre try…catch.
Tu avais trouvé ?
Exercice 4 sur 4 Accélérer le démarrage Réussi À revoir
Ce code met environ 1,2 seconde à s'exécuter : chaque requête prend environ 400 ms. Réécris-le pour qu'il prenne environ 400 ms, sachant que les trois requêtes sont indépendantes.
const pieces = await chargerJson('/api/pieces');
const categories = await chargerJson('/api/categories');
const fournisseurs = await chargerJson('/api/fournisseurs');
Voir la réponseMasquer la réponse de l'exercice 4
const [pieces, categories, fournisseurs] = await Promise.all([
chargerJson('/api/pieces'),
chargerJson('/api/categories'),
chargerJson('/api/fournisseurs'),
]);
Les trois requêtes partent en même temps ; Promise.all attend la plus lente et renvoie les résultats dans l'ordre du tableau, pas dans l'ordre d'arrivée. Si l'une échoue, tout échoue : c'est le bon comportement ici, puisque l'application a besoin des trois.
Tu avais trouvé ?