fetch et async/await expliqués simplement : les erreurs qu'on ne voit pas
Beaucoup d'erreurs du code asynchrone ne plantent rien. Une requête vers un serveur en panne renvoie undefined, une fonction de secours ne se déclenche jamais, une boucle annonce zéro résultat alors que le serveur a bien répondu trois fois. Rien n'apparaît en rouge, ou alors un message qui parle d'autre chose que du vrai problème.
Cette page passe en revue sept de ces erreurs, chacune reproduite sur une fausse API locale. Pour chaque cas, vous trouverez le code fautif, ce qu'il affiche réellement, puis la correction. À la fin, une fonction chargerJSON d'une dizaine de lignes donne un message clair à chacune des quatre pannes que le serveur peut renvoyer ; les deux autres, réseau coupé et CORS refusé, rejettent déjà d'elles-mêmes.
Tout le code a été exécuté le 21 octobre 2026 sous Windows 11, dans Node 22.23.3 et dans Chromium 153.0.8010.12 (le navigateur de Playwright 1.63.0). Les sorties citées sont copiées telles quelles. Si async et await vous sont nouveaux, retenez seulement ceci pour commencer : await met la fonction en pause jusqu'à ce que la promesse soit tenue ou rejetée, et un rejet se comporte alors comme une exception qu'un try…catch peut attraper.
Une fausse API pour provoquer les pannes
Pour voir une erreur, il faut pouvoir la déclencher à volonté. Ce petit serveur Node, sans aucune dépendance, renvoie un utilisateur en JSON, une page 404 en HTML, une erreur 500, une réponse qui met cinq secondes à venir et des articles qui en mettent 0,3. Il écoute sur deux ports : le second sert la même API depuis une autre origine, ce qui nous servira pour le CORS.
// serveur.mjs : une fausse API qui provoque chaque panne de l'article (Node 18 ou plus)
import { createServer } from "node:http";
const pause = (ms) => new Promise((fin) => setTimeout(fin, ms));
function json(res, statut, objet) {
res.writeHead(statut, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(objet));
}
async function repondre(req, res) {
const { pathname } = new URL(req.url, "http://127.0.0.1");
if (pathname === "/") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
return res.end('<!doctype html><meta charset="utf-8"><title>Banc fetch</title>');
}
if (pathname === "/api/utilisateur/1") return json(res, 200, { id: 1, nom: "Ada" });
if (pathname === "/api/panne") return json(res, 500, { erreur: "base indisponible" });
if (pathname === "/api/lent") {
await pause(5000);
return json(res, 200, { id: 1, nom: "Ada" });
}
const article = pathname.match(/^\/api\/article\/(\d+)$/);
if (article) {
await pause(300);
return json(res, 200, { id: Number(article[1]), titre: `Article ${article[1]}` });
}
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
res.end("<!doctype html><title>404</title><h1>Page introuvable</h1>");
}
createServer(repondre).listen(3000, "127.0.0.1");
createServer(repondre).listen(3001, "127.0.0.1"); // même API, autre origine, sans en-tête CORS
console.log("API prête sur http://127.0.0.1:3000/api");Lancez-le avec node serveur.mjs. Chaque exemple de la suite commence par ces deux constantes ; pour les essayer, collez-les avec l'exemple dans un fichier .mjs exécuté par Node 18 ou plus, ou dans un <script type="module"> d'une page servie par le serveur, sur http://127.0.0.1:3000/.
const API = "http://127.0.0.1:3000/api";
const AUTRE = "http://127.0.0.1:3001/api"; // même serveur, autre origineUne 404 n'est pas une erreur pour fetch
Voici la fonction que l'on écrit tous la première fois : on demande l'URL, on lit le JSON, on renvoie le nom. Le try…catch donne l'impression que tout est prévu.
async function chargerNom(url) {
const reponse = await fetch(url);
const donnees = await reponse.json();
return donnees.nom;
}
for (const chemin of ["/utilisateur/1", "/utilisateur/999", "/panne"]) {
try {
console.log(chemin, "→", await chargerNom(API + chemin));
} catch (erreur) {
console.log(chemin, "→ erreur :", erreur.message);
}
}Node affiche ceci :
/utilisateur/1 → Ada
/utilisateur/999 → erreur : Unexpected token '<', "<!doctype "... is not valid JSON
/panne → undefinedDeux pannes, et aucune des deux n'est signalée pour ce qu'elle est. Pour la 404, l'erreur attrapée ne vient pas de fetch : elle vient de reponse.json(), qui a essayé de lire comme du JSON la page HTML d'erreur. Le message parle d'un caractère <, pas d'une page introuvable. Pour la 500, c'est pire : le serveur a répondu du JSON valide, {"erreur":"base indisponible"}, qui n'a pas de propriété nom. Résultat : undefined, sans la moindre exception.
Ce comportement est voulu. Un statut d'erreur ne suffit pas à rejeter la promesse de fetch : quand le serveur répond 404 ou 500, elle est tenue avec un objet Response. Elle est rejetée dans d'autres situations, dont les trois que ce banc reproduit plus bas : serveur injoignable, CORS refusé, délai dépassé. La documentation MDN le dit en toutes lettres : si le serveur répond par une erreur comme 404, fetch() se résout avec une Response, et c'est à vous de vérifier le statut avant de lire le corps.
Dans Chromium, la même boucle affiche les trois mêmes lignes. La console du navigateur ajoute en rouge « Failed to load resource: the server responded with a status of 404 (Not Found) », et la même ligne pour la 500, mais ce sont des journaux du navigateur, pas des erreurs de votre code : ils ne passent par aucun catch.
Vérifier reponse.ok avant de lire le corps
La correction tient en trois lignes. reponse.ok vaut true pour un statut de 200 à 299 et false sinon. On lève une erreur qui dit ce qui s'est vraiment passé, avant de toucher au corps :
async function chargerJSON(url) {
const reponse = await fetch(url);
if (!reponse.ok) {
throw new Error(`HTTP ${reponse.status} sur ${url}`);
}
return reponse.json();
}La même boucle, réécrite avec cette fonction :
for (const chemin of ["/utilisateur/1", "/utilisateur/999", "/panne"]) {
try {
const utilisateur = await chargerJSON(API + chemin);
console.log(chemin, "→", utilisateur.nom);
} catch (erreur) {
console.log(chemin, "→ erreur :", erreur.message);
}
}/utilisateur/1 → Ada
/utilisateur/999 → erreur : HTTP 404 sur http://127.0.0.1:3000/api/utilisateur/999
/panne → erreur : HTTP 500 sur http://127.0.0.1:3000/api/panneLes deux pannes arrivent enfin dans le catch, avec le statut et l'URL. Notez le return reponse.json() final, sans await : ici il est sans danger, parce que la fonction ne contient aucun try. Nous verrons plus bas pourquoi la même ligne pose un problème dès qu'elle est placée dans un try. L'application météo de ce site applique ce contrôle dans un vrai projet, avec deux appels enchaînés : son code est détaillé dans le tutoriel créer une application météo avec une API gratuite.
Le await oublié ne lève rien
Une fonction async renvoie toujours une promesse. Si on oublie await devant l'appel, on récupère la promesse elle-même, pas son résultat. Rien ne l'interdit, donc rien ne le signale :
const utilisateur = chargerJSON(`${API}/utilisateur/1`); // await oublié
console.log("Bonjour " + utilisateur.nom);
console.log("Bonjour " + utilisateur);Bonjour undefined
Bonjour [object Promise]La requête est pourtant partie et elle a réussi : la promesse sera bien tenue avec { id: 1, nom: "Ada" }, mais après que les deux lignes ont été affichées. Une promesse n'a pas de propriété nom, d'où undefined, et une fois convertie en texte elle devient [object Promise]. Si ce texte s'affiche un jour dans votre page, cherchez un await manquant dans les lignes qui le précèdent. La correction : const utilisateur = await chargerJSON(…).
return sans await : le catch qui n'attrape rien
Celle-ci est la plus sournoise, parce que le code a l'air prudent. On veut un utilisateur « Invité » quand le chargement échoue. Deux versions ne diffèrent que d'un mot :
async function chargerSansAwait(url) {
try {
return chargerJSON(url);
} catch (erreur) {
console.log("secours activé :", erreur.message);
return { nom: "Invité" };
}
}
async function chargerAvecAwait(url) {
try {
return await chargerJSON(url);
} catch (erreur) {
console.log("secours activé :", erreur.message);
return { nom: "Invité" };
}
}
for (const charger of [chargerSansAwait, chargerAvecAwait]) {
try {
const utilisateur = await charger(`${API}/utilisateur/999`);
console.log(charger.name, "→", utilisateur.nom);
} catch (erreur) {
console.log(charger.name, "→ l'appelant reçoit :", erreur.message);
}
}chargerSansAwait → l'appelant reçoit : HTTP 404 sur http://127.0.0.1:3000/api/utilisateur/999
secours activé : HTTP 404 sur http://127.0.0.1:3000/api/utilisateur/999
chargerAvecAwait → InvitéDans chargerSansAwait, le return renvoie la promesse de chargerJSON tout de suite, alors qu'elle n'est pas encore rejetée. La fonction sort de son try sans erreur ; quand le rejet arrive, plus aucun catch local n'est là pour le recevoir, et c'est l'appelant qui l'encaisse. Le secours n'est jamais activé. Avec return await, la fonction attend le résultat à l'intérieur du try, le rejet devient une exception à cet endroit, et le catch fait son travail. Règle pratique : dans un try, écrivez toujours return await.
forEach n'attend pas vos fonctions async
Charger trois articles, il y a trois façons de l'écrire. Le serveur met 0,3 seconde à répondre pour chacun.
const ids = [1, 2, 3];
let debut = performance.now();
const enSerie = [];
for (const id of ids) {
enSerie.push((await chargerJSON(`${API}/article/${id}`)).titre);
}
const dureeSerie = performance.now() - debut;
console.log("for...of :", enSerie.length, "titres");
debut = performance.now();
const enParallele = await Promise.all(ids.map((id) => chargerJSON(`${API}/article/${id}`)));
const dureeParallele = performance.now() - debut;
const rapport = Math.round(dureeSerie / dureeParallele);
console.log("Promise.all :", enParallele.length, "titres,", rapport, "fois plus vite");
const titres = [];
ids.forEach(async (id) => {
const article = await chargerJSON(`${API}/article/${id}`);
titres.push(article.titre);
});
console.log("forEach :", titres.length, "titre(s) au moment du log");
setTimeout(() => console.log("500 ms plus tard :", titres.length, "titre(s)"), 500);for...of : 3 titres
Promise.all : 3 titres, 3 fois plus vite
forEach : 0 titre(s) au moment du log
500 ms plus tard : 3 titre(s)Node et Chromium affichent exactement ces quatre lignes. Le code affiche un rapport arrondi plutôt que des durées, parce que celles-ci varient d'une exécution à l'autre : sur 40 exécutions dans Node, for...of a pris de 922 à 981 ms et Promise.all de 306 à 315 ms. forEach appelle la fonction pour chaque élément, mais il ignore la promesse qu'elle renvoie : il ne peut donc pas l'attendre. MDN le dit explicitement : forEach() attend une fonction synchrone et n'attend pas les promesses. Les requêtes partent bien, le tableau se remplit plus tard, mais la ligne suivante s'exécute avant. Si vous devez utiliser les résultats juste après, forEach est le mauvais outil.
Entre les deux bonnes versions, le choix dépend du besoin. for...of avec await envoie une requête après l'autre : c'est plus lent, mais l'ordre est garanti et une erreur arrête la boucle. Promise.all les envoie toutes en même temps et rend les résultats dans l'ordre du tableau de départ ; il est rejeté dès que l'une d'elles échoue. Sur trois requêtes de 0,3 seconde, cela fait près d'une seconde contre un tiers de seconde.
Une promesse rejetée sans catch : Node s'arrête, le navigateur continue
Appeler une fonction async sans await ni .catch(), c'est lancer une promesse dont personne n'écoutera l'échec. Le même code a deux destins très différents selon l'endroit où il tourne :
async function enregistrer() {
await chargerJSON(`${API}/panne`);
console.log("enregistré");
}
enregistrer(); // ni await ni catch
console.log("la suite du script continue");
setTimeout(() => console.log("une seconde plus tard"), 1000);Dans Node 22, le script affiche « la suite du script continue », puis s'arrête avec le code de sortie 1. Le terminal montre la ligne fautive du code, puis cette erreur, suivie de la pile d'appels : Error: HTTP 500 sur http://127.0.0.1:3000/api/panne. « une seconde plus tard » ne s'affiche jamais. C'est le mode par défaut de Node depuis sa version 15 : un rejet non géré est traité comme une exception non attrapée, qui termine le processus. Pour un serveur, cela veut dire qu'une seule requête ratée sans catch peut l'arrêter.
Dans Chromium, la page ne s'arrête pas : le navigateur signale l'erreur comme non attrapée (Playwright la reçoit comme une erreur de page), puis « une seconde plus tard » s'affiche normalement. L'utilisateur, lui, ne voit rien du tout. Le bouton « Enregistrer » semble avoir fonctionné alors que rien n'a été enregistré. Dans les deux cas, la correction est la même : un await dans un try…catch, ou au minimum un .catch() au bout de l'appel, qui prévient l'utilisateur. Le guide pour manipuler le DOM en JavaScript montre comment écrire ce message dans la page plutôt que dans la console.
CORS refusé ou serveur éteint : le même message
Deux requêtes qui échouent pour des raisons sans rapport : la première vise l'API sur l'autre port, qui n'envoie pas d'en-tête Access-Control-Allow-Origin ; la seconde vise un port où rien n'écoute.
const adresses = [`${AUTRE}/utilisateur/1`, "http://127.0.0.1:3999/api"];
for (const url of adresses) {
try {
const reponse = await fetch(url);
console.log(url, "→ statut", reponse.status);
} catch (erreur) {
console.log(url, "→", erreur.name + " : " + erreur.message);
}
}Exécuté depuis la page http://127.0.0.1:3000/ dans Chromium :
http://127.0.0.1:3001/api/utilisateur/1 → TypeError : Failed to fetch
http://127.0.0.1:3999/api → TypeError : Failed to fetchCôté code, impossible de distinguer les deux. La vraie raison n'apparaît que dans la console du navigateur : pour la première, « Access to fetch at 'http://127.0.0.1:3001/api/utilisateur/1' from origin 'http://127.0.0.1:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. », pour la seconde « net::ERR_CONNECTION_REFUSED ». Là encore, c'est voulu : pour des raisons de sécurité, le détail d'un échec CORS n'est jamais transmis au JavaScript, explique MDN, et seule la console permet de savoir ce qui s'est passé. Devant un « Failed to fetch », ouvrez donc l'onglet Console avant de modifier votre code.
Dans Node, le même script donne un tout autre résultat : la première requête réussit (statut 200), parce que le CORS est une protection des navigateurs et que Node n'en applique aucune ; la seconde échoue avec TypeError : fetch failed. Un appel qui fonctionne dans un script Node peut donc échouer dans la page, sans que le serveur ait changé.
Choisir soi-même le délai d'attente
Aucun des exemples précédents ne fixe de délai. Si le serveur met cinq secondes, la page attend cinq secondes, avec un bouton qui semble ne rien faire et aucun message. Des limites internes existent selon l'environnement : dans Node 22, fetch repose sur la bibliothèque undici (version 6.28.1 dans le Node du banc), qui abandonne par défaut après 300 secondes sans en-têtes de réponse. Cinq minutes protègent contre une connexion bloquée, mais aucun utilisateur n'attend aussi longtemps devant un bouton. AbortSignal.timeout() permet de choisir votre propre limite en une ligne :
async function attendre(options) {
const debut = performance.now();
const duree = () => Math.round((performance.now() - debut) / 1000);
try {
await fetch(`${API}/lent`, options);
return `réponse après ${duree()} s`;
} catch (erreur) {
return `${erreur.name} après ${duree()} s`;
}
}
console.log("sans délai :", await attendre({}));
console.log("avec délai :", await attendre({ signal: AbortSignal.timeout(2000) }));sans délai : réponse après 5 s
avec délai : TimeoutError après 2 sMêmes deux lignes dans Node et dans Chromium. À l'expiration, la promesse est rejetée avec une erreur dont le nom est TimeoutError, ce qui permet de la distinguer des autres pannes par erreur.name. Son message, lui, change d'un moteur à l'autre : « The operation was aborted due to timeout » dans Node, « signal timed out » dans Chromium. Ne comparez jamais des messages d'erreur dans votre code : comparez le nom. Si vous travaillez déjà avec setTimeout, le minuteur Pomodoro en JavaScript montre d'autres façons dont le temps réel s'écarte de ce qu'on a demandé.
Une fonction qui nomme chaque panne du serveur
Voici la version finale de chargerJSON. Elle ajoute deux contrôles à celle du début : une limite de temps, réglable, et une vérification du type de contenu. Ce second contrôle couvre un cas que reponse.ok ne voit pas : un serveur qui renvoie une page HTML avec un statut 200 là où l'on attendait du JSON, par exemple la page d'accueil en réponse à une URL d'API mal écrite. Le dernier essai ci-dessous le reproduit.
async function chargerJSON(url, { delai = 8000 } = {}) {
const reponse = await fetch(url, { signal: AbortSignal.timeout(delai) });
if (!reponse.ok) {
throw new Error(`HTTP ${reponse.status} sur ${url}`);
}
const type = reponse.headers.get("Content-Type") ?? "";
if (!type.includes("application/json")) {
throw new Error(`${type || "réponse sans type"} au lieu de JSON sur ${url}`);
}
return reponse.json();
}Cinq essais : un succès et quatre pannes (une 404, une 500, une réponse trop lente, une page HTML servie en 200).
const essais = [
[`${API}/utilisateur/1`],
[`${API}/utilisateur/999`],
[`${API}/panne`],
[`${API}/lent`, { delai: 2000 }],
["http://127.0.0.1:3000/"], // la page HTML elle-même, servie en 200
];
for (const [url, options] of essais) {
try {
console.log("OK →", (await chargerJSON(url, options)).nom);
} catch (erreur) {
console.log(erreur.name, "→", erreur.message);
}
}OK → Ada
Error → HTTP 404 sur http://127.0.0.1:3000/api/utilisateur/999
Error → HTTP 500 sur http://127.0.0.1:3000/api/panne
TimeoutError → The operation was aborted due to timeout
Error → text/html; charset=utf-8 au lieu de JSON sur http://127.0.0.1:3000/C'est la sortie de Node ; Chromium affiche les mêmes lignes, sauf celle du délai, qui devient « TimeoutError → signal timed out ». Chaque panne arrive dans le catch avec un message qui dit ce qui s'est passé. Il reste les pannes réseau et CORS, qui rejettent déjà d'elles-mêmes avec un TypeError. En TypeScript, la variable erreur d'un catch est de type unknown en mode strict : l'article unknown vs any en TypeScript explique comment la vérifier avant de lire erreur.message.
Le banc, les versions et les sources
Un script de mesure démarre le serveur ci-dessus, assemble chaque exemple avec ses constantes et sa fonction chargerJSON, l'exécute dans un processus Node séparé, puis dans un contexte Chromium neuf piloté par Playwright, et compare chaque sortie à celle publiée ici. Les sorties de Node ont été obtenues avec Node 22.23.3 sous Windows 11 ; celles de Chromium avec Chromium 153.0.8010.12 et Playwright 1.63.0. Les durées dépendent de la machine : les exemples affichent donc un rapport arrondi ou des secondes entières, et les durées brutes citées plus haut viennent d'un script séparé du banc, lancé deux fois sur 20 exécutions.
Pages consultées le 21 octobre 2026 :
- MDN, Using the Fetch API, section « Checking response status » : rejet sur une erreur réseau, promesse tenue avec une
Responsesur une 404,okvrai pour la plage 200 (statuts 200 à 299). - MDN, Array.prototype.forEach() : la fonction passée doit être synchrone,
forEach()n'attend pas les promesses qu'elle renvoie. - Documentation de Node 22, options de la ligne de commande, section
--unhandled-rejections: modethrowpar défaut depuis la version 15.0.0, le rejet non géré est levé comme une exception non attrapée. - MDN, CORS errors : le détail d'un échec CORS n'est pas disponible pour le JavaScript, seule la console du navigateur le montre.
- Documentation de Node 22, objets globaux, section
fetch: implémentation fondée sur undici, version lisible dansprocess.versions.undici. - undici, documentation de Client :
headersTimeoutetbodyTimeoutvalent300e3millisecondes par défaut. - MDN, AbortSignal.timeout() : le signal est interrompu avec une
DOMExceptionnomméeTimeoutError.