Comment lire une pile d'appels JavaScript
Lire une stack trace JavaScript: repérez le premier frame utile, comprenez les trous async, les bundles minifiés, les source maps et Error.cause.
Une pile d’appels (stack trace) JavaScript se lit à rebours dans le temps : la frame du haut correspond à l’appel qui a levé l’erreur, et chaque frame en dessous correspond à l’appel qui y a mené.
Le réflexe habituel consiste à parcourir rapidement la première ligne, à la coller dans un moteur de recherche et à croiser les doigts. Ça fonctionne jusqu’au jour où la frame du haut appartient à React, ou à JSON.parse, ou à un bundle où chaque fonction s’appelle o.
Cet article décortique une seule pile d’appels du début à la fin, en ajoutant à chaque section une clé de lecture : quelle frame ouvrir réellement, ce que vous disent les frames async, à quoi ressemble une pile minifiée, et pourquoi la pile derrière Error.cause n’apparaît jamais dans celle que vous avez affichée.
Points clés à retenir
- La frame du haut indique où l’erreur a été levée, pas où le bug a été introduit ; l’enquête commence à la première frame pointant vers un fichier que vous avez écrit.
- Les frames marquées
asyncsont reconstruites par V8 à partir des points où chaqueawaits’est interrompu puis a repris :await,Promise.all()etPromise.any()sont donc raccordés, alors qu’une simple chaîne.then()laisse un trou. - V8 ne conserve que 10 frames par défaut, un nombre modifiable via la propriété non standard
Error.stackTraceLimit. new Error(message, { cause })préserve l’erreur d’origine, mais le moteur ne fusionne pas les deux piles :err.stackn’affiche que l’enveloppe.
À quoi ressemble une pile d’appels JavaScript ?
Une pile d’appels JavaScript, c’est un nom d’erreur et un message sur la première ligne, suivis d’une frame par appel, la plus récente en premier. Voici la pile d’appels sur laquelle cet article revient sans cesse : un fichier de panier est lu sur le disque, analysé, puis transmis au reste de l’application.
SyntaxError: Unexpected token 'b', "{ bad json" is not valid JSON
at JSON.parse (<anonymous>)
at parseCart (/app/src/cart.js:5:15)
at loadCart (/app/src/cart.js:10:20)
at main (/app/src/main.js:6:16)
at Object.<anonymous> (/app/src/main.js:12:1)
Lisez les frames dans cet ordre et l’enchaînement devient limpide : JSON.parse a levé l’erreur, parseCart l’a appelée, loadCart a appelé parseCart, et ainsi de suite. La frame du bas correspond au point de départ de cette chaîne d’appels précise, qui n’est pas toujours le point d’entrée du programme : du code atteint via un gestionnaire de clic, un callback de timer ou une promesse résolue démarre une nouvelle chaîne, qui commence au callback.
Une frame vous donne quatre informations : le nom de la fonction, le fichier, la ligne et la colonne. Dans du code minifié, seule la colonne a une valeur quelconque sans source map. Une réserve qu’il vaut mieux connaître d’emblée : Error.prototype.stack n’est pas normalisée. Tous les moteurs la fournissent, chacun affiche une chaîne légèrement différente, et les travaux de TC39 visant à figer le format ne sont pas terminés. Les exemples présentés ici suivent le format de V8, qui couvre Chrome, Edge et Node.
La frame du haut n’est généralement pas votre code
La frame du haut d’une pile d’appels n’est généralement pas votre code. C’est la bibliothèque, le framework ou la fonction native qui a détecté la valeur incorrecte, ce qui signifie qu’elle vous dit ce qui a cassé, pas pourquoi. Dans la pile ci-dessus, at JSON.parse (<anonymous>) correspond au parseur natif signalant que la chaîne qu’on lui a transmise n’est pas du JSON valide. Il n’y a rien à corriger à cet endroit.
La frame qui vous intéresse est la première qui pointe vers un fichier que vous avez écrit. Il s’agit de parseCart, à /app/src/cart.js:5:15. Ouvrez cette ligne et vous y trouvez l’appel à JSON.parse, ce qui vous indique que la chaîne incorrecte est arrivée sous forme d’argument. La valeur provient donc de la frame en dessous : loadCart, à la ligne 10, qui a lu le fichier. Voilà le véritable point de départ, et la question qu’il soulève : d’où venait le contenu du fichier, et pourquoi rien ne l’a validé ?
Cet ordre de lecture se généralise. Descendez en ignorant les chemins node_modules, les frames <anonymous> et native jusqu’à atteindre votre propre fichier, puis remontez le fil vers le bas à travers les frames qui ont fourni la valeur.
Une pile d’appels enregistre le chemin emprunté par le programme jusqu’à l’échec, jamais celui emprunté par l’utilisateur. C’est pourquoi deux rapports aux frames identiques peuvent correspondre à un correctif de cinq minutes ou à un fantôme irreproductible. Le session replay comble cette moitié manquante : vous lisez les frames pour savoir où ça a cassé, et vous visionnez la session pour comprendre comment l’application est arrivée dans un état où cela pouvait casser.
Frames asynchrones et frontière de l’await
Déclarez loadCart comme async et la pile franchit l’await, les frames reconstruites étant préfixées par async :
SyntaxError: Unexpected token 'b', "{ bad json" is not valid JSON
at JSON.parse (<anonymous>)
at parseCart (/app/src/cart.js:5:15)
at async loadCart (/app/src/cart.js:10:20)
at async main (/app/src/main.js:6:16)
Ces frames préfixées async ne sont pas capturées comme le sont les frames synchrones. V8 les reconstruit à partir des sites d’await, et il peut le faire sans surcoût car un await reprend exactement à l’endroit où il s’était interrompu.
Cette reconstruction a ses limites, et c’est là que la pile s’amincit. Le raccordement couvre les points d’await, Promise.all() et Promise.any(), et rien d’autre : une promesse renvoyée sans être attendue, ou une chaîne .then(), laisse donc un trou précisément là où se trouvait le contexte appelant. Si les frames s’arrêtent brutalement à une frontière asynchrone, cherchez un await manquant dans la couche supérieure. Aucun drapeau à activer : --async-stack-traces est actif par défaut depuis V8 v7.3, de sorte que les frames asynchrones apparaissent dans Chrome, dans toutes les versions maintenues de Node et dans les autres runtimes V8 actuels.
L’autre raison pour laquelle des frames disparaissent, c’est le plafond. V8 conserve 10 frames et jette le reste, et Error.stackTraceLimit est le curseur qui règle ce nombre : une nouvelle valeur s’applique aux erreurs créées après sa définition, et toute valeur non numérique, ou inférieure à zéro, vous laisse sans aucune frame.
if (process.env.NODE_ENV !== 'production') {
Error.stackTraceLimit = Infinity;
}
Réservez ce réglage au développement. Les piles profondes coûtent de la mémoire, noient les frames intéressantes dans le bruit et propagent chemins de fichiers et noms de fonctions dans des logs susceptibles d’être expédiés ailleurs. Cette propriété est non standard : elle est née dans V8, et JavaScriptCore l’a copiée par compatibilité, donc la définir ne casse rien nulle part, mais la valeur par défaut et les détails fins dépendent du moteur.
Comment lire une pile minifiée ?
Face à un bundle de production, le même échec produit des frames de ce genre. Considérez cette forme comme illustrative de ce que produit un bundler, plutôt que comme le format exact d’un outil particulier :
SyntaxError: Unexpected token 'b', "{ bad json" is not valid JSON
at JSON.parse (<anonymous>)
at o (/assets/index-4f1c8a2b.js:1:20874)
at async s (/assets/index-4f1c8a2b.js:1:21036)
La signature est sans ambiguïté : des noms de fonctions d’une seule lettre, un unique nom de fichier, la ligne 1 et une colonne à cinq ou six chiffres. Ligne 1 et une colonne énorme signifient que tout le graphe de modules tient sur une seule ligne : la colonne est donc la seule coordonnée porteuse d’information. parseCart et loadCart existent toujours à ce décalage de colonne, mais rien dans la chaîne ne vous révélera leurs noms.
Les retrouver exige une source map accessible à l’outillage, générée au moment du build et téléversée à un endroit où la pile pourra être résolue. Notre guide sur le fonctionnement des source maps couvre le format et la configuration du build.
Error.cause ne fusionne pas les piles
Envelopper une erreur avec new Error(message, { cause: originalError }) préserve l’objet d’erreur d’origine, avec son type et sa propre pile, mais le moteur ne fusionne pas les deux piles. err.stack n’affiche que l’enveloppe, et l’originale n’est accessible que via err.cause.stack.
export async function loadCart(path) {
const raw = await readFile(path, 'utf8');
try {
return parseCart(raw);
} catch (err) {
throw new Error(`Cart file ${path} is not valid JSON`, { cause: err });
}
}
Les appelants reçoivent désormais un message nommant le fichier, et err.cause instanceof SyntaxError reste vrai. Ce qu’ils n’obtiennent pas, c’est la frame JSON.parse : la pile de l’enveloppe démarre au throw à l’intérieur de loadCart. Error.cause est arrivée avec ES2022 et fonctionne dans les navigateurs actuels ainsi que dans toutes les versions maintenues de Node, jusqu’à Node 16.9.0. Elle est définie comme une propriété propre non énumérable : elle reste donc invisible pour Object.keys(), for...in et un JSON.stringify() naïf de l’erreur.
La quantité de chaîne affichée par une console varie selon le runtime et la console, donc l’approche portable consiste à la parcourir vous-même :
function printChain(error) {
let current = error;
while (current instanceof Error) {
console.error(current.stack);
current = current.cause;
}
}
Cela affiche les frames de l’enveloppe, puis celles du parseur, dans l’ordre où elles ont été levées.
Deux habitudes qui effacent la piste
Attraper une erreur et en lever une nouvelle sans transmettre de cause supprime la pile d’origine du programme. Les frames qui vous auraient indiqué où la valeur incorrecte est entrée n’existent plus nulle part, et aucune recherche dans les logs ne les fera revenir :
catch (err) {
throw new Error('Could not load cart'); // JSON.parse frame is gone
}
Étouffer l’erreur dans une ligne de log inflige les mêmes dégâts, plus discrètement :
catch (err) {
console.log('cart load failed'); // message, no stack, no type
return [];
}
Dans les deux cas, il ne manque qu’un mot-clé pour que tout aille bien. Passez { cause: err } lorsque vous relancez l’erreur, et loguez err lui-même plutôt qu’une phrase à son sujet.
La prochaine fois qu’une pile d’appels atterrit sous vos yeux, ne commencez pas par la première ligne. Descendez jusqu’à la première frame portant le nom d’un de vos fichiers, ouvrez cette ligne et demandez-vous quelle valeur lui a été transmise, et par qui. Si les frames s’arrêtent à une frontière async ou sur une fonction d’une seule lettre, vous avez affaire à un trou de raccordement ou à une source map manquante, pas à l’histoire complète.
FAQ
Pourquoi mon gestionnaire d'erreurs ne rapporte-t-il que 'Script error.' sans pile d'appels ?
Les navigateurs masquent les détails des exceptions levées par des scripts cross-origin : window.onerror reçoit donc le texte générique 'Script error.' sans URL, numéro de ligne ni pile exploitables. Pour obtenir le vrai message et les vraies frames, chargez le script avec l'attribut crossorigin défini à anonymous et assurez-vous que le serveur qui l'héberge renvoie un en-tête Access-Control-Allow-Origin couvrant votre origine. La plupart des CDN publics envoient déjà cet en-tête.
Error.captureStackTrace fonctionne-t-elle en dehors de Chrome et Node ?
Elle n'est plus réservée à V8. Error.captureStackTrace est née dans V8 dans le cadre de son API non standard de piles d'appels, et les autres moteurs ont depuis suivi : JavaScriptCore l'a livrée dans Safari 17.2, sorti le 11 décembre 2023, et SpiderMonkey dans Firefox 138, sorti le 29 avril 2025. L'appeler écrit une chaîne de pile sur l'objet que vous lui passez. Comme elle reste non normalisée, protégez l'appel par une vérification typeof Error.captureStackTrace avant de l'utiliser dans du code de bibliothèque partagé.
Pourquoi les piles d'appels sont-elles différentes dans Firefox et dans Chrome ?
Error.prototype.stack se situe en dehors de toute spécification : chaque moteur l'affiche donc à sa guise et le contenu varie. V8 écrit chaque frame sur une ligne commençant par 'at', tandis que Firefox utilise une forme functionName@file:line:column sans un tel préfixe. Traitez la chaîne de pile comme une sortie lisible par un humain, pas comme une API analysable, et ne bâtissez jamais un regroupement d'erreurs sur une simple expression régulière faite maison.
Puis-je capturer une pile d'appels sans lever d'erreur ?
Oui. Dans la plupart des moteurs actuels, la pile est renseignée au moment où vous construisez l'Error, pas au moment où vous la levez : const { stack } = new Error() vous fournit donc la pile d'appels sur-le-champ, sans throw ni catch. La frame du haut correspond à la ligne qui a créé l'erreur, et le plafond habituel de frames s'applique toujours : V8 n'en conserve que 10, sauf si Error.stackTraceLimit est augmentée.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k