Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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, catch et finally ;
  • écrire une fonction async qui attend avec await et gère les erreurs ;
  • appeler une API avec fetch, vérifier response.ok et 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é.

js
// À 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.

js
console.log('1. début');
setTimeout(() => console.log('3. une seconde plus tard'), 1000);
console.log('2. fin du script');
text
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.

Pile d'appelsune seule choseà la foisrafraichir()script principalAPI du navigateur : l'attente se passe icisetTimeout, fetch,addEventListener…confiequand c'est prêtFile des tâchesrappels de setTimeout, clics, saisies…② puis une seule tâcheFile des microtâchessuites des promesses : then, catch, après await① d'abord toutes les microtâchesLa boucle d'événements attend que la pile soit vide, vide la file des microtâches,exécute une tâche, met l'affichage à jour si besoin, et recommence.
La pile exécute, le navigateur attend, et la boucle d'événements rend la main dans un ordre précis : microtâches d'abord, puis une tâche

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 :

js
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');
text
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 :

js
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 :

ÉtatSensCe qui s'exécute
En attente (pending)L'opération est en coursRien pour l'instant
Tenue (fulfilled)L'opération a réussi, avec une valeurLes fonctions passées à then
Rompue (rejected)L'opération a échoué, avec une erreurLes 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 :

js
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 un then pré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 :

js
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 async renvoie toujours une promesse, même si elle fait return 42. Celui qui l'appelle doit donc l'attendre à son tour avec await (ou then).
  • Oublier await ne provoque pas d'erreur : tu obtiens la promesse au lieu de la valeur.
js
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 :

js
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éthodeContenu
reponse.statusLe code de statut : 200, 201, 404, 500… (codes HTTP)
reponse.oktrue 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 :

await fetch(url)promesse rejetéepromesse tenueAucune réponseréseau coupé, serveur arrêté,CORS refusé, délai dépasséTypeError, TimeoutErrorUne réponse arrivemême un 404 ou un 500reponse.ok ?false4xx, 5xxtrue200 à 299throw new Error(…)fetch ne le fait pasawait reponse.json()les donnéescatch (erreur)prévenir l'utilisateur, garder l'application utilisable
fetch ne rejette que s'il n'y a pas de réponse : un 404 ou un 500 se détecte en testant response.ok
SituationCe que fait fetchCe que tu dois écrire
Réseau coupé, serveur arrêté, nom de domaine inconnuRejette avec TypeError (Chrome : Failed to fetch)try…catch autour du await fetch
Requête bloquée par CORSRejette avec TypeError ; le détail n'est que dans la consoleCorriger la configuration CORS du serveur (CORS)
Délai dépassé (si tu en as fixé un)Rejette avec TimeoutErrorsignal: AbortSignal.timeout(ms)
Réponse 4xx ou 5xxTient sa promesse, avec ok à falseif (!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 ContentCorps videNe 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 :

js
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.

js
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 :

js
// 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 :

js
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.

js
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 ?

js
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.

js
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
js
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.

js
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
js
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é ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Modules ES et classes : organiser le codeRevoir le sommaire du cours