Les erreurs async ne se comportent pas comme les erreurs synchrones
En JavaScript synchrone, quand une erreur est lancée, elle remonte la pile d'appels jusqu'à ce qu'un try/catch l'attrape — sinon, le programme plante. En async, ce modèle vole en éclats. Au moment où une requête réseau échoue, la fonction qui l'a déclenchée a déjà rendu la main depuis longtemps. Il n'y a plus aucune pile d'appels à remonter.
Les promesses résolvent ce problème en offrant aux erreurs leur propre canal. Une promesse peut soit être tenue avec une valeur, soit rejetée avec une raison. Le rejet, c'est l'équivalent async du throw. Tout ce qui suit sur cette page vise un seul objectif : s'assurer que chaque rejet de promesse atterrit quelque part sous votre contrôle, plutôt que de se volatiliser dans la nature.
Le try/catch s'exécute et se termine sans broncher. Le rejet, lui, survient 50 ms plus tard, bien après la sortie du bloc try. Plus personne pour l'attraper. Voilà le piège.
try/catch fonctionne à nouveau avec await
Dès l'instant où vous faites await sur une promesse, un rejet se transforme en erreur levée à l'intérieur de la fonction async. Un try/catch qui l'entoure l'intercepte alors comme n'importe quelle exception synchrone :
C'est le premier réflexe à adopter. await fait le pont entre le monde asynchrone et la bonne vieille structure try/catch qu'on connaît tous. Tu places les appels await susceptibles d'échouer dans le bloc try, et tu les récupères dans le catch.
Un détail important à garder en tête : seul l'appel qui est réellement await est couvert. Si tu lances une promesse sans l'attendre, les erreurs passent à travers les mailles du filet.
Le bug le plus fréquent : oublier le await
Quand tu appelles une fonction async sans await (et sans retourner sa promesse non plus), les rejets échappent au try/catch qui entoure l'appel :
Le bloc try se termine sans encombre. Le rejet, lui, survient au tick suivant, sans personne pour le rattraper. Résultat : un bon vieux warning « unhandled promise rejection » dans la console.
La solution est toujours la même : soit on await l'appel, soit on return la promesse pour que l'appelant puisse l'attendre lui-même.
.catch(), l'autre façon de gérer les rejets
Si tu n'utilises pas async/await, tu peux traiter les rejets de promesse en chaînant un .catch() :
.catch(fn) est en réalité un raccourci pour .then(undefined, fn). Il intercepte n'importe quel rejet survenu plus tôt dans la chaîne. Un .catch() placé en bout de chaîne joue le rôle d'un try/catch global pour le code asynchrone — c'est la dernière ligne de défense avant que le rejet ne devienne « non géré ».
Rien n'empêche de combiner les deux styles. Un schéma courant consiste d'ailleurs à utiliser async/await à l'intérieur d'une fonction et à laisser l'appelant y accrocher un .catch() :
fetch ne rejette pas sur les erreurs HTTP
Celle-là, tout le monde se fait avoir au moins une fois. fetch ne rejette que sur les erreurs réseau : résolution DNS échouée, connexion refusée, requête annulée. Une réponse 404 ou 500 est considérée comme un appel réussi. La promesse est bien résolue — simplement, elle l'est avec une réponse dont ok vaut false.
Si tu veux que les erreurs HTTP atterrissent dans ton bloc catch, vérifie res.ok et lance une exception toi-même :
Dès que tu te retrouves à écrire ça deux fois, extrais-le dans un helper.
Promise.all échoue vite, Promise.allSettled non
Promise.all prend un tableau de promesses et renvoie un tableau de résultats — sauf si l'une d'elles est rejetée, auquel cas la promesse globale est rejetée immédiatement avec cette erreur. Les autres promesses continuent de tourner en arrière-plan, mais leurs résultats partent à la poubelle.
Le fail-fast est le bon comportement quand tu as besoin de tous les résultats et qu'un seul échec rend l'opération entière inutile. Mais quand tu veux connaître le sort de chaque promesse — du genre « lance ces cinq uploads et dis-moi lesquels ont réussi et lesquels ont échoué » — utilise plutôt Promise.allSettled :
allSettled ne rejette jamais. Chaque entrée est soit {status: "fulfilled", value}, soit {status: "rejected", reason}.
Relancer l'erreur et cibler le catch
Toutes les erreurs n'ont pas leur place dans le même gestionnaire. Un pattern courant consiste à attraper l'erreur, l'inspecter, puis relancer tout ce qu'on n'avait pas prévu :
Avaler toutes les erreurs avec un catch (err) {} vide masque les vrais bugs. Attrapez ce que vous pouvez traiter concrètement, et relancez le reste.
Le rejet de promesse non géré : votre filet de sécurité
Même avec du code soigné, quelque chose finit toujours par passer entre les mailles. Node.js comme les navigateurs exposent un hook global pour capter les rejets que personne n'a attrapés :
// Navigateur
window.addEventListener("unhandledrejection", event => {
console.error("unhandled:", event.reason);
event.preventDefault(); // supprime l'avertissement par défaut de la console
});
// Node.js
process.on("unhandledRejection", reason => {
console.error("unhandled:", reason);
});
Ce n'est pas un remplacement pour une vraie gestion d'erreurs — c'est plutôt un filet de sécurité pour logger ou envoyer de la télémétrie. Dans Node.js moderne, un rejet de promesse non géré fait planter le processus par défaut, et c'est généralement ce qu'on veut en production. Tu logges l'erreur, puis tu laisses le processus mourir et redémarrer proprement.
Une checklist pratique
Dès qu'une fonction async touche à quelque chose qui peut échouer, pose-toi ces questions :
- Est-ce que chaque
awaitrisqué est bien dans untry/catch, ou est-ce que la promesse retournée est gérée par l'appelant avec.catch()? - Est-ce que j'
awaitvraiment l'appel, ou est-ce que j'ai lancé un fire-and-forget par erreur ? - Pour
fetchen particulier, est-ce que je vérifieres.okavant de faire confiance à la réponse ? - Quand je lance des trucs en parallèle, est-ce que
Promise.allest le bon outil, ou est-ce que je veux plutôtPromise.allSettled? - Est-ce qu'il y a un
.catch()au niveau racine ou un handlerunhandledrejectionpour que rien ne disparaisse en silence ?
Maîtrise ces cinq points et ton code async arrêtera de te surprendre avec des erreurs qui s'évaporent dans l'event loop.
La suite : les modules ES
La gestion des erreurs async vient clore ce chapitre sur l'asynchrone. Dans la suite, on voit comment le code JavaScript est organisé entre plusieurs fichiers — import, export, et le système de modules qui est la base de tout projet moderne.
Questions fréquentes
Comment gérer les erreurs dans une fonction async ?
Enveloppez vos appels await dans un bloc try/catch. Toute promesse rejetée devient une erreur levée que le catch récupère. Vous pouvez aussi laisser l'erreur remonter et la traiter côté appelant avec un .catch() sur la promesse retournée.
Pourquoi mon try/catch n'attrape pas l'erreur ?
La plupart du temps, c'est parce que l'erreur survient dans du code qui n'est pas awaité. Si vous appelez une fonction async sans await (ou sans retourner sa promesse), le rejet échappe au try/catch englobant. Pensez toujours à await ou à return la promesse dont vous voulez capter les erreurs.
Que se passe-t-il si une promesse est rejetée et que personne ne l'attrape ?
Vous obtenez un rejet non géré (unhandled rejection). Node.js déclenche l'événement unhandledRejection et, dans les versions récentes, crashe le processus par défaut. Dans le navigateur, c'est window.onunhandledrejection qui se déclenche avec un avertissement en console. Dans tous les cas, branchez un .catch() ou un try/catch autour du await.
Comment Promise.all gère-t-il les erreurs ?
Promise.all rejette dès qu'une des promesses en entrée est rejetée. Les autres continuent de s'exécuter, mais leurs résultats sont ignorés. Si vous voulez récupérer toutes les issues, succès comme échecs, utilisez plutôt Promise.allSettled : il renvoie un tableau d'objets {status, value} ou {status, reason}.