Explication de la pile d’appels JavaScript : frames, débordements, traces de pile async/await
Comment l’stack d’appel de JavaScript fonctionne étape par étape, pourquoi les stacks débordent, où se trouvent les closures, et comment lire les traces d’erreur async/await dans DevTools et Node.js.
Une question classique d’entretien présente quelques appels de fonctions imbriqués et demande ce qui est affiché et dans quel ordre. Chaque fonction est simple en soi ; ce qui embrouille les gens, c’est la séquence. Cette séquence ne relève pas de l’intuition. Elle est déterminée par un mécanisme : la pile d’appels. Une fois que vous pouvez l’imaginer, les questions relatives à l’affichage deviennent un exercice mécanique, les débordements de pile cessent d’être mystérieux et les traces d’erreurs se transforment en cartes précises. Ce guide explique le fonctionnement de la pile, suit le déroulement d’un programme frame par frame, explique pourquoi les piles débordent et comment y remédier, puis montre comment utiliser les outils DevTools et les traces de pile pour déboguer du code réel.
Pourquoi un langage monolithique a besoin d’une pile d’appels
Tout le reste découle d’une décision de conception : JavaScript exécute votre code sur une seule thread. C’est intentionnel, et non une négligence. À tout moment, le moteur exécute exactement une partie de votre code, jamais deux en parallèle.
Cela ne signifie pas pour autant qu’il n’y ait rien qui se passe en même temps. Les temporiseurs, les requêtes réseau et les opérations sur fichiers sont gérés par l’environnement hôte (les Web APIs du navigateur ou Node.js), en dehors du moteur JavaScript. Les navigateurs délèguent ce travail à des pools de threads natifs ou à des mécanismes d’E/S du système d’exploitation tels que epoll, communiquant parfois entre processus via IPC. Lorsque le travail en arrière-plan est terminé, son callback est placé dans une file d’attente de tâches. L’attente a lieu en parallèle, mais les callbacks eux-mêmes s’exécutent toujours un après l’autre sur la même thread unique.
Si cela vous semble abstrait, imaginez un restaurant disposant d’un seul cuisinier. Ce cuisinier est rapide et bien organisé, mais il ne peut pas couper des légumes et remuer une casserole en même temps. Chaque tâche est terminée et mise de côté avant que la suivante ne commence. Le moteur JavaScript fonctionne de la même manière.
La question intéressante est de savoir ce qui se passe lorsque l’une des tâches déclenche une autre. Prenons trois petites fonctions où chacune appelle la suivante :
function multiply(a, b) {
return a * b;
}
function square(n) {
return multiply(n, n);
}
function printSquare(n) {
const result = square(n);
console.log(result);
}
printSquare(5);
Lorsque printSquare appelle square, printSquare ne peut pas simplement continuer ; il a d’abord besoin du résultat. Il faut donc que quelque chose se souvienne que printSquare est en pause, à quel endroit précisément il s’est arrêté, et que le contrôle doive revenir à square, et non au script de niveau supérieur, une fois que multiply a terminé.
Cette gestion est assurée par la pile d’appels. C’est le registre du moteur indiquant ce qui est en cours d’exécution et ce qui attend de reprendre. Sans elle, une fonction n’aurait aucun moyen de retourner à l’endroit approprié et sa valeur de retour n’aurait nulle part où aller. Il ne s’agit pas d’une optimisation en arrière-plan ; c’est le mécanisme réel par lequel JavaScript exécute les appels de fonction.
Deux types de mémoire : la pile, le tas et les closures
Une question fréquente chez les débutants est la suivante : si une machine dispose de 16 ou 32 Go de RAM, pourquoi un programme JavaScript épuise-t-il sa pile après seulement quelques milliers d’appels imbriqués ? La réponse est que des moteurs comme V8 divisent la mémoire en deux régions très différentes : la pile d’appels et le heap.
TWO SIDES OF JAVASCRIPT MEMORY:
CALL STACK (Execution & Order) MEMORY HEAP (Storage & Data)
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ [Frame: square(5)] │ │ Object: { name: "Alice" } │
│ n: 5 │ │ Array: [10, 20, 30, 40] │
│ ref: 0x8F3A ──────────────┼─>│ Function: multiply() │
├─────────────────────────────┤ │ Object: { role: "admin" } │
│ [Global Execution Context] │ │ Large Strings & Closures │
└─────────────────────────────┘ └─────────────────────────────┘
- Fast, ordered (LIFO) - Large, unstructured
- Small, fixed memory limit - Gigabytes dynamic storage
- Auto-cleared on return - Cleaned by Garbage Collect
La pile d’appel est optimisée pour la vitesse et un ordre strict. Il s’agit d’une zone de taille fixe et petite qui contient des cadres d’exécution, des adresses de retour et des valeurs locales primitives. Comme elle suit strictement le principe du dernier entré, premier sorti, jeter un cadre terminé est presque gratuit : le moteur se contente de déplacer un pointeur de pile.
Le tas est un pool bien plus grand et organisé de manière plus souple. Les objets, les tableaux, les fonctions et les closures y résident, et la pile ne contient qu’une référence légère vers eux, représentée dans le diagramme par une adresse telle que 0x8F3A. La mémoire du tas est récupérée par le collecteur de déchets et non en quittant une fonction.
Cette séparation explique le paradoxe. Un débordement de pile ne signifie pas que votre ordinateur manque de mémoire. Cela signifie que la petite zone dédiée à suivre les appels en cours est pleine. Considérez ce diagramme comme un modèle mental : les moteurs réels présentent plus de subtilités, par exemple les variables capturées par des closures sont stockées dans des contextes alloués en heap plutôt que dans la pile elle-même.
La forme de la pile : dernier entré, premier sorti
La pile d’appels est une structure LIFO : dernier entré, premier sorti. Ce qui a été ajouté récemment est le premier à être retiré. Imaginez la pile de assiettes propres dans une cantine : les assiettes neuves se trouvent en haut, et on en prend toujours la du dessus ; on ne prend jamais celles du milieu ou du bas.
Les appels de fonction suivent exactement les mêmes règles :
Every time you call a function → a new "plate" (frame) is placed on TOP
Every time a function finishes → its "plate" is removed from the TOP
JavaScript always runs the function whose plate is currently on TOP
Qu’est-ce qu’un cadre de pile contient
Chaque cadre de pile est étroitement lié à ce que la spécification appelle un contexte d’exécution. Conceptuellement, il enregistre cinq éléments :
- La fonction et ses arguments, tels que
a = 5, b = 5. - Les variables locales déclarées à l’intérieur du corps de la fonction.
- L’adresse de retour, c’est-à-dire l’instruction dans la fonction appelante où l’exécution doit se poursuivre une fois cette fonction terminée.
- Une référence à la chaîne de portée, l’environnement lexical externe qui permet à la fonction de lire des variables provenant des portées imbriquées.
- Le liant
thisqui s’applique lors de l’appel.
Un détail peut facilement être manqué. Avant que l’une de vos fonctions ne s’exécute, le moteur crée le contexte d’exécution global du script et le place en premier. Il constitue le fond de la pile pendant l’exécution de votre code de niveau supérieur.
WHEN YOUR SCRIPT STARTS:
CALL STACK:
┌──────────────────────────────────┐
│ Global Execution Context (main) │ <-- Always here first
└──────────────────────────────────┘
Chaque appel est empilé sur cette base, et une règle les relie tous : le moteur exécute toujours le cadre situé en haut de l’empilement. Dès qu’un nouveau cadre est ajouté, celui qui se trouve en dessous est mis en pause.
Suivre un programme cadre par cadre
Suivons printSquare(5) à travers l’empilement. Le faire avec soin une seule fois suffit généralement pour rendre le processus automatique. Voici à nouveau le programme :
function multiply(a, b) {
return a * b;
}
function square(n) {
return multiply(n, n);
}
function printSquare(n) {
const result = square(n);
console.log(result);
}
printSquare(5);
La chaîne d’appels est printSquare, puis square à l’intérieur, et enfin multiply à l’intérieur de celui-ci.
Étape 1 : le script démarre
Avant que printSquare(5) ne s’exécute, le moteur commence à traiter le fichier et crée le contexte d’exécution global.
STEP 1: Script starts running
CALL STACK:
┌──────────────────────────────────┐
│ Global Execution Context (main) │ <-- Pushed first, always
└──────────────────────────────────┘
OUTPUT SO FAR: (nothing yet)
Le moteur enregistre également les définitions de multiply, square et printSquare. Définir une fonction ne fait rien ; ce n’est qu’en l’appelant qu’un cadre est créé.
Étape 2 : appeler printSquare(5)
Le moteur atteint printSquare(5), crée un cadre pour celui-ci et le place en haut.
STEP 2: printSquare(5) is called
CALL STACK:
┌──────────────────────────────────┐
│ printSquare(5) │ <-- PUSHED on top
├──────────────────────────────────┤
│ Global Execution Context (main) │
└──────────────────────────────────┘
OUTPUT SO FAR: (nothing yet)
printSquare est maintenant en haut, donc il s’exécute. Sa première instruction, const result = square(n), est une autre appel, ce qui oblige printSquare à céder le contrôle avant de pouvoir faire quoi que ce soit d’autre.
Étape 3 : printSquare appelle square(5)
Un cadre pour square(5) est placé au-dessus de printSquare.
STEP 3: square(5) is called inside printSquare
CALL STACK:
┌──────────────────────────────────┐
│ square(5) │ <-- PUSHED on top
├──────────────────────────────────┤
│ printSquare(5) │ <-- Waiting, paused
├──────────────────────────────────┤
│ Global Execution Context (main) │
└──────────────────────────────────┘
OUTPUT SO FAR: (nothing yet)
printSquare est en pause, en attente d’une valeur. Toute l’attention se porte sur square, dont l’unique instruction, return multiply(n, n), déclenche une nouvelle appel.
Étape 4 : square appelle multiply(5, 5)
Une frame supplémentaire s’ajoute, et la pile atteint quatre frames, ce qui représente son point le plus profond dans ce programme.
STEP 4: multiply(5, 5) is called inside square -- Stack peak!
CALL STACK:
┌──────────────────────────────────┐
│ multiply(5, 5) │ <-- TOP - runs now
├──────────────────────────────────┤
│ square(5) │ <-- Waiting
├──────────────────────────────────┤
│ printSquare(5) │ <-- Waiting
├──────────────────────────────────┤
│ Global Execution Context (main) │
└──────────────────────────────────┘
OUTPUT SO FAR: (nothing yet)
multiply n’appelle rien d’autre. Il évalue 5 * 5, renvoie 25, et termine son exécution, ce qui permet d’effacer son cadre d’exécution.
Étape 5 : multiply renvoie une valeur et son cadre est supprimé
Lorsqu’une fonction est terminée, son cadre d’exécution est supprimé du sommet de la pile. Le cadre situé juste en dessous, ici square, redevient le cadre supérieur et reprend son exécution exactement à l’endroit où il s’était arrêté.
STEP 5: multiply(5, 5) returns 25 -- Frame popped
CALL STACK:
┌──────────────────────────────────┐
│ square(5) │ <-- NOW at top, resumes
├──────────────────────────────────┤
│ printSquare(5) │ <-- Still waiting
├──────────────────────────────────┤
│ Global Execution Context (main) │
└──────────────────────────────────┘
RETURN VALUES SO FAR:
1. multiply(5, 5) --> returned 25 (passed back to square)
Pour square, l’expression return multiply(n, n) est désormais devenue return 25. Elle renvoie cette valeur qui est ensuite retirée à son tour.
Étape 6 : square renvoie la valeur et printSquare reprend
STEP 6: square(5) returns 25 -- Frame popped
CALL STACK:
┌──────────────────────────────────┐
│ printSquare(5) │ <-- NOW at top, resumes
├──────────────────────────────────┤
│ Global Execution Context (main) │
└──────────────────────────────────┘
RETURN VALUES SO FAR:
1. multiply(5, 5) --> returned 25 (passed back to square)
2. square(5) --> returned 25 (stored as const result inside printSquare)
Dans printSquare, l’attribution const result = square(n) se termine avec result égal à 25. L’instruction suivante, console.log(result), affiche 25.
Étape 7 : le journal d’erreurs s’exécute et la pile se déroule
STEP 7: console.log(25) executes, then printSquare finishes -- Frame popped
CALL STACK:
┌──────────────────────────────────┐
│ Global Execution Context (main) │ <-- Program done
└──────────────────────────────────┘
FINAL OUTPUT:
1. multiply(5, 5) --> returned 25
2. square(5) --> returned 25
3. console.log(25) --> PRINTED: 25
4. printSquare(5) --> finished (returns undefined implicitly)
printSquare renvoie implicitement undefined et est supprimé, ne laissant que le contexte global. La seule valeur qui atteint la console est le 25 enregistré par printSquare ; les valeurs de retour de multiply et square ont été transmises silencieusement.
Toute la trace en un coup d’œil
Les mêmes sept étapes, compressées pour une consultation rapide :
STEP 1: Script starts
Stack: Global
Output: (none)
STEP 2: printSquare(5) called
Stack: printSquare -> Global
Output: (none)
STEP 3: square(5) called
Stack: square -> printSquare -> Global
Output: (none)
STEP 4: multiply(5, 5) called
Stack: multiply -> square -> printSquare -> Global
Output: (none)
STEP 5: multiply returns 25
Stack: square -> printSquare -> Global
Output: (none)
STEP 6: square returns 25
Stack: printSquare -> Global
Output: (none)
STEP 7: console.log(25), printSquare done
Stack: Global
Output: 25
Quatre règles résument tout ce qui a été dit ci-dessus :
- Une appelation ajoute un nouveau cadre en haut de la pile. À chaque fois.
- Un retour retire ce cadre de la pile. À chaque fois.
- Le cadre le plus en haut est celui qui est en cours d’exécution. Aucune exception.
- Le cadre appelant est celui situé directement en dessous. C’est là que le contrôle est transféré lorsque la fonction actuelle retourne.
Lorsque la pile n’a plus de place : débordement de pile
Ce que signifie réellement cette erreur
La pile a une taille fixe, définie par le moteur. Chaque cadre en consomme une partie pour les arguments, les variables locales, l’adresse de retour et les opérations de suivi. Si suffisamment de cadres sont ajoutés sans en être retirés, l’espace disponible s’épuise. Le moteur lance alors une RangeError :
RangeError: Maximum call stack size exceeded
C’est un stack overflow. Le célèbre site de questions-réponses destiné aux programmeurs porte en fait son nom de cet erreur précise. La cause habituelle est une fonction qui continue de s’appeler elle-même sans aucun mécanisme pour l’arrêter, ce qui représente une récursion défaillante.
Récursion infinie
Le exemple le plus simple est une fonction dont la seule fonction est de s’appeler elle-même :
// No base case -- this never stops
function forever() {
return forever();
}
forever(); // RangeError: Maximum call stack size exceeded
Visualisé, l’empilement augmente jusqu’à ce que le moteur refuse d’aller plus loin :
STACK GROWING OUT OF CONTROL:
┌──────────────────────────────────┐
│ forever() │ <-- frame #10,000+ ... crash!
├──────────────────────────────────┤
│ forever() │
├──────────────────────────────────┤
│ forever() │
├──────────────────────────────────┤
│ forever() │
├──────────────────────────────────┤
│ forever() │
├──────────────────────────────────┤
│ ... │ (thousands more)
├──────────────────────────────────┤
│ forever() │ <-- frame #1
├──────────────────────────────────┤
│ Global Execution Context (main) │
└──────────────────────────────────┘
RESULT: RangeError: Maximum call stack size exceeded
Chaque appel à forever() ajoute une frame, et comme rien ne retourne jamais, rien n’est jamais retiré. Finalement, l’espace réservé est épuisé et une RangeError est levée.
La récursivité en soi n’est pas le problème. C’est une technique légitime et souvent élégante. Le problème réside dans une récursivité sans issue. Chaque fonction récursive a besoin d’un cas de base, c’est-à-dire d’une condition sous laquelle elle cesse de s’appeler elle-même et retourne une valeur.
Un cas de base qui n’est jamais atteint
Une version plus subtile trompe de nombreux développeurs. La fonction ci-dessous dispose bien d’un cas de base, mais son étape récursive avance dans la mauvaise direction :
function countDown(n) {
if (n === 0) return; // Base case IS here, but never reached
countDown(n + 1); // Bug: going UP, not down -- n never reaches 0
}
countDown(1); // RangeError: Maximum call stack size exceeded
La condition de protection if (n === 0) return existe, mais l’appel récursif transmet n + 1. À partir de 1, les valeurs augmentent progressivement pour atteindre 2, 3, 4 et ainsi de suite, de sorte que zéro n’est jamais atteint et la pile finit par déborder. La version corrigée compte à rebours et utilise n <= 0 comme mesure de protection, permettant ainsi d’arrêter le processus même en cas de valeur de départ négative ou fractionnaire inattendue :
function countDown(n) {
if (n <= 0) return; // Base case: stop when we reach or pass zero
console.log(n);
countDown(n - 1); // Subtracting: we're actually getting closer to 0
}
countDown(5); // Prints: 5, 4, 3, 2, 1
Récursion indirecte
Le cycle peut également englober plusieurs fonctions. Ici, ping appelle pong, et pong appelle à son tour ping :
function ping() {
pong(); // ping calls pong
}
function pong() {
ping(); // pong calls ping back: circular loop
}
ping(); // RangeError: Maximum call stack size exceeded
Aucune des deux fonctions ne s’appelle elle-même, mais le couple forme un boucle infinie d’appels, et le résultat est le même débordement. Les cycles indirects sont plus difficiles à détecter dans du code réel, car les deux parties se trouvent souvent dans des modules différents, comme un gestionnaire d’événements qui déclenche une mise à jour qui provoque à son tour ce même événement.
Jusqu’à quelle profondeur peut aller la pile ?
La profondeur maximale dépend du moteur ainsi que de la quantité de mémoire nécessaire à chaque frame. Les valeurs indiquées ci-dessous sont des estimations approximatives et non des garanties ; elles varient en fonction des versions, des plateformes et des paramètres de configuration, il est donc nécessaire de mesurer dans son propre environnement cible si cette limite est importante :
CALL STACK LIMITS BY RUNTIME (APPROXIMATE):
- Chrome & Node.js (V8 Engine): ~10,000 to 12,000 frames
- Safari (JavaScriptCore Engine): ~40,000 to 45,000 frames
- Firefox (SpiderMonkey Engine): ~50,000+ frames
La raison de cette variation réside dans le fait que la limite correspond en réalité à un budget de mémoire, et non au nombre d’appels. Une fonction comportant de nombreux paramètres et variables locales génère des frames plus volumineux, ce qui fait qu’il en faut moins avant d’atteindre ce plafond.
Résoudre les débordements de pile en pratique
Lorsque la récursivité profonde provoque une panne dans du code réel, quelques patterns fiables permettent de y remédier.
Remplacer la récursivité par une boucle
Lorsque les données d’entrée sont volumineuses, réécrivez la récursivité sous forme de boucle for ou while. Une boucle s’exécute au sein d’un seul frame, de sorte que des millions d’itérations n’augmentent pas du tout la profondeur de la pile. Dans l’exemple ci-dessous, la sommation récursive risque de déborder à une profondeur d’environ 20 000 dans V8, tandis que la version itérative gère sans problème un million d’éléments :
// Recursive version: crashes if n is 20,000
function sumRecursive(n) {
if (n === 0) return 0;
return n + sumRecursive(n - 1);
}
// Iterative version: uses 1 stack frame, handles any n safely
function sumIterative(n) {
let total = 0;
for (let i = 1; i <= n; i++) {
total += i;
}
return total;
}
console.log(sumIterative(1000000)); // 500000500000 (instant, zero stack growth)
Trampoline
Si vous préférez un style récursif, un trampoline permet de le maintenir sans augmenter la profondeur de la pile. Au lieu de s’appeler lui-même, la fonction récursive renvoie un « thunk », c’est-à-dire une fonction sans argument qui décrit l’étape suivante. Le trampoline exécute ces thunks un par un dans une boucle while simple ; chaque thunk renvoie avant que le suivant ne commence, ce qui permet à la profondeur de la pile de rester constante.
function trampoline(fn) {
return function (...args) {
let result = fn(...args);
while (typeof result === "function") {
result = result();
}
return result;
};
}
// Return a function thunk instead of a direct recursive call
function safeSum(n, acc = 0) {
if (n === 0) return acc;
return () => safeSum(n - 1, acc + n);
}
const runSum = trampoline(safeSum);
console.log(runSum(100000)); // 5000050000 (no stack overflow!)
Prêtez attention au paramètre accumulateur acc dans safeSum : la somme cumulée est transmise dans les arguments plutôt que d’être stockée sur la pile. Le compromis consiste en une allocation supplémentaire de fonction à chaque étape ainsi qu’en une convention moins évidente pour les lecteurs ; il est donc préférable de limiter l’utilisation des trampolines aux algorithmes réellement récursifs.
Évitez de disperser de grands tableaux dans des appels
Une surprise fréquente : lorsqu’un tableau très grand est transmis dans un appel de fonction, chaque élément est passé en tant qu’argument distinct, et ces arguments nécessitent également de l’espace sur la pile.
const hugeArray = new Array(150000).fill(1);
// CRASH: RangeError: Maximum call stack size exceeded
// Math.max(...hugeArray);
// SAFE: Process with a loop or reduce
const maxVal = hugeArray.reduce((max, val) => (val > max ? val : max), -Infinity);
En fonction du moteur, une appelation comme Math.max(...hugeArray) peut provoquer la même erreur RangeError lorsque le tableau contient une centaine de milliers d’éléments ou plus. Une fonction reduce ou une boucle classique traite les données sans cette limite.
Ne comptez pas sur l’optimisation des appels en queue
Les tutoriels plus anciens ainsi que la spécification ECMAScript 2015 décrivent les Proper Tail Calls (PTC), souvent appelés optimisation des appels en queue. L’idée est que lorsque l’action finale d’une fonction en mode strict consiste à renvoyer le résultat d’un autre appel, le moteur peut réutiliser le cadre actuel au lieu d’en créer un nouveau, ce qui permettrait à une récursion infinie de fonctionner avec un espace de pile constant.
En pratique, parmi les principaux moteurs, seul JavaScriptCore de Safari l’implémente. V8, utilisé par Chrome et Node.js, ainsi que SpiderMonkey de Firefox ont choisi de ne pas le fournir pour le JavaScript standard, principalement parce que l’élimination des frames efface l’historique sur lequel s’appuient les traces d’exécution et les débogueurs. Considérez les appels en chaîne comme indisponibles dans du code en production et utilisez des boucles ou des mécanismes de saut pour les récursions profondes.
Prédire la sortie dans les questions d’entretien
Les questions sur la sortie sont en réalité des questions sur la pile d’appels
Les questions du type « Qu’est-ce que ce code affiche ? » apparaissent dans presque tous les entretiens JavaScript, et la plupart ne sont en réalité que des exercices sur la pile d’appels déguisés. L’entreteneur ne teste pas la mémorisation ; il vérifie si vous comprenez l’ordre d’exécution, ordre qui est entièrement déterminé par la pile. Essayez celle-ci avant de continuer :
function a() {
console.log("a start");
b();
console.log("a end");
}
function b() {
console.log("b start");
c();
console.log("b end");
}
function c() {
console.log("c");
}
a();
Le suivi image par image
Le programme affiche :
a start
b start
c
b end
a end
Beaucoup de gens s’attendent à ce que a end apparaisse plus tôt. Voici pourquoi ce n’est pas le cas. L’appel à a() envoie son cadre en avant, et a commence à s’exécuter.
CALL STACK after a() is called:
┌──────────────────┐
│ a() │ <-- running
├──────────────────┤
│ Global Context │
└──────────────────┘
La première instruction dans a enregistre un démarrage. La suivante est l’appel b(), de sorte qu’un cadre pour b est placé en haut de la pile.
CALL STACK after b() is called inside a:
┌──────────────────┐
│ b() │ <-- running now
├──────────────────┤
│ a() │ <-- paused, waiting for b to finish
├──────────────────┤
│ Global Context │
└──────────────────┘
a est actuellement en pause, et sa dernière instruction console.log("a end") n’a pas encore été exécutée ; elle ne le pourra que lorsque b sera complètement terminé. À l’intérieur de b, b start est enregistré, puis c() est appelé, ce qui ajoute un troisième cadre.
CALL STACK after c() is called inside b:
┌──────────────────┐
│ c() │ <-- running now
├──────────────────┤
│ b() │ <-- paused
├──────────────────┤
│ a() │ <-- paused
├──────────────────┤
│ Global Context │
└──────────────────┘
c enregistre c, n’a plus rien à faire et retourne. Son cadre est supprimé, et b redevient en haut.
CALL STACK after c() returns:
┌──────────────────┐
│ b() │ <-- resumes now
├──────────────────┤
│ a() │ <-- still waiting
├──────────────────┤
│ Global Context │
└──────────────────┘
b continue immédiatement après son appel à c(), enregistre b end, puis retourne. Son cadre est retiré, ce qui remet a en tête de la pile.
CALL STACK after b() returns:
┌──────────────────┐
│ a() │ <-- resumes now
├──────────────────┤
│ Global Context │
└──────────────────┘
a continue après son appel à b(), enregistre a end, retourne et est retiré de la pile. Seul le contexte global reste. Le résultat complet, dans l’ordre :
a start
b start
c
b end
a end
La règle à internaliser : un appelant ne passe pas à l’étape suivante tant que la fonction appelée n’a pas entièrement terminé. a end a dû attendre que b soit terminé, ce qui à son tour a attendu que c soit terminé. a a été la première fonction à effectuer un appel externe et donc la dernière à se terminer.
Une méthode en trois étapes pour toute question liée à une sortie
- Énumérez les appels dans l’ordre, en partant du plus externe pour aller vers l’intérieur. Chaque appel fait pause à son appelant.
- Suivez le sommet de la pile. Ce qui se trouve en haut s’exécute ; tout ce qui se trouve en dessous attend.
- Placez chaque journalisation. Les journalisations provenant d’une fonction appelée apparaissent avant toute journalisation issue de l’appelant.
Le schéma est toujours le même : descendre jusqu’à l’appel le plus profond, puis remonter dans l’ordre inverse. Cela concerne le code synchrone. Les appels de retour provenant de compteurs temporels, de promesses et d’événements ajoutent une file d’attente à ce modèle ; notre article sur les closures et le cycle d’événements traite de cette couche supplémentaire.
Inspecter la pile en temps réel dans Chrome DevTools
Le suivi sur papier est idéal pour les entretiens, mais au quotidien, vous regarderez généralement la pile directement dans le débogueur du navigateur.
Pauser avec l’instruction debugger
En ajoutant debugger; sur une ligne, l’exécution s’arrête à cet endroit chaque fois que DevTools est ouvert. Ouvrez-le avec F12, ou cliquez avec le bouton droit sur la page, choisissez « Inspecter », puis passez à l’onglet « Sources ».
function calculateTax(subtotal) {
const rate = 0.08;
debugger; // Execution pauses right here
return subtotal * rate;
}
function checkoutCart() {
const total = 100;
const tax = calculateTax(total);
console.log(total + tax);
}
checkoutCart();
Lorsque l’exécution s’arrête à la ligne debugger, la barre latérale affiche la pile d’appel actuelle ainsi que les variables en scope :
DEVTOOLS SOURCES PANEL BREAKDOWN:
┌───────────────────────┐ ┌──────────────────────────────────────────────┐
│ CALL STACK PANE │ │ SCOPE PANE (Updates based on clicked frame) │
├───────────────────────┤ ├──────────────────────────────────────────────┤
│ calculateTax (line 3)│ │ Local Scope: │
│ checkoutCart (line 9)│ │ subtotal: 100 │
│ (anonymous) (line 13│ │ rate: 0.08 │
└───────────────────────┘ └──────────────────────────────────────────────┘
La fenêtre Call Stack affiche calculateTax en haut, checkoutCart en dessous, et le script anonyme de niveau supérieur en bas, ce qui correspond à la même structure observée précédemment. En cliquant sur checkoutCart, l’éditeur se déplace à la ligne 9, et la fenêtre Scope passe aux variables de ce cadre, affichant total: 100 exactement comme lorsqu’on a appelé calculateTax. La capacité à examiner l’état d’un appelateur en pause est l’une des techniques de débogage les plus utiles.
Avancer pas à pas à travers les appels
La barre d’outils du débogueur propose trois commandes de progression qui correspondent directement aux opérations de la pile :
- Step Over (F10) exécute la ligne actuelle et s’arrête à la ligne suivante de la même fonction. Si cette ligne appelle une fonction, cet appel s’exécute entièrement sans que le débogueur ne s’arrête à l’intérieur.
- Step Into (F11) suit l’appel de la ligne en cours, en insérant un nouveau cadre d’exécution et en s’arrêtant à sa première instruction.
- Step Out (Shift+F11) exécute le reste de la fonction en cours, retire son cadre d’exécution et s’arrête dans la fonction appelante juste après le retour de l’appel.
Avec ces trois commandes, le débogage devient une exploration méthodique de la pile d’exécution plutôt qu’une série de tentatives aléatoires.
Traces de pile : la pile d’appels au moment de la panne
Ce que vous apprend une trace de pile
Lorsqu’une erreur est générée et non capturée, le moteur génère une trace de pile : un aperçu de chaque cadre d’exécution présent sur la pile au moment de l’erreur. Elle indique ce qui était en cours d’exécution, qui l’a appelé, ainsi que la ligne exacte où le problème est survenu. Une trace n’est rien d’autre que la pile d’appels écrite sous forme de texte, donc tout ce qui a été expliqué jusqu’à présent s’applique également à sa lecture.
Les traces semblent souvent intimidantes, un ensemble de chemins et de nombres, mais une fois leur structure maîtrisée, elles représentent le chemin le plus rapide pour identifier un bug.
Anatomie d’une trace
Prenons une petite chaîne dans laquelle la fonction la plus interne lance une exception :
function c() {
throw new Error("something went wrong");
}
function b() {
c();
}
function a() {
b();
}
a();
Lorsqu’on le lance avec Node.js, il produit un résultat comme celui-ci :
Error: something went wrong
at c (/box/index.js:2:9)
at b (/box/index.js:6:3)
at a (/box/index.js:10:3)
at Object.<anonymous> (/box/index.js:13:1)
at Module._compile (node:internal/modules/cjs/loader:1705:14)
at Object..js (node:internal/modules/cjs/loader:1838:10)
at Module.load (node:internal/modules/cjs/loader:1441:32)
at Function._load (node:internal/modules/cjs/loader:1263:12)
at TracingChannel.traceSync (node:diagnostics_channel:328:14)
at wrapModuleLoad (node:internal/modules/cjs/loader:237:24)
La première ligne indique le type d’erreur et le message, Error et quelque chose ne s’est pas passé comme prévu. Voici ce qui a causé le problème. Parmi les autres types que vous rencontrerez fréquemment, on trouve TypeError, ReferenceError, SyntaxError et RangeError.
Chaque ligne commençant par at correspond à un niveau de la pile d’exécution au moment où l’erreur s’est produite, dans ce format :
at functionName (filename:lineNumber:columnNumber)
Ainsi, at c (/box/index.js:2:9) signifie que la fonction c était en exécution dans /box/index.js, à la ligne 2, colonne 9, qui correspond précisément à l’instruction throw.
at Object.<anonymous> indique du code exécuté au niveau le plus élevé d’un module, en dehors de toute fonction nommée. Ici, a() est appelé depuis le niveau le plus élevé du module. Ailleurs dans le trace, <anonymous> indique généralement un callback intégré qui n’a pas de nom.
Les lignes suivantes, telles que Module._compile, Module.load et wrapModuleLoad, sont des éléments internes de Node.js qui chargent et exécutent votre fichier. Vous ne les avez pas écrits et vous pouvez généralement les ignorer.
Comment lire
C’est l’ordre qui prête à confusion. Le dernier cadre est imprimé en premier, ce qui fait que la liste suit l’ordre inverse de celui des appels effectués :
Read this direction (for debugging):
↑ TOP = where the error actually happened
|
| at c (/box/index.js:2:9) <-- Error thrown HERE
| at b (/box/index.js:6:3) <-- b called c
| at a (/box/index.js:10:3) <-- a called b
| at Object.<anonymous> (...) <-- global code called a
↓
BOTTOM = where execution started
La partie inférieure du trace correspond au point d’entrée, là où l’exécution a commencé. La partie supérieure indique l’endroit où l’erreur est survenue. L’exécution a progressé vers le haut à travers les différents frames jusqu’à ce que la levée d’exception l’arrête.
Pour déboguer, commencez par la partie supérieure et ouvrez le fichier ainsi que la ligne correspondant à la première occurrence de at : c’est là que la panne s’est produite. Ensuite, continuez à lire vers le bas, car la cause réelle se trouve souvent un ou deux frames plus bas. L’erreur est apparue dans c, mais quel appelant a fourni des données incorrectes à c ? Vérifiez b. Et qui a appelé b de cette manière ? Vérifiez a. Le frame en haut montre les symptômes ; les frames en dessous contiennent généralement la cause.
Une routine fiable pour tout trace :
- Lisez le type et le message d’erreur pour comprendre le type d’échec survenu.
- Localisez la première ligne
atqui fait référence à votre propre code, en ignorant les frames liés au temps d’exécution et aux bibliothèques. - Ouvrez ce fichier et cette ligne pour examiner ce qui s’y passe.
- Inspectez le frame juste en dessous, car la valeur erronée ou l’appel incorrect proviennent souvent du code qui a fait l’appel.
Affichage de la pile sur demande avec console.trace()
Vous pouvez afficher la pile actuelle à tout moment, sans erreur, en appelant console.trace():
function deepFunction() {
console.trace("Who called me?");
// prints current call stack right here
}
function middle() {
deepFunction();
}
function outer() {
middle();
}
outer();
Le résultat a l’air de ceci :
Trace: Who called me?
at deepFunction (/box/index.js:2:11)
at middle (/box/index.js:7:3)
at outer (/box/index.js:11:3)
at Object.<anonymous> (/box/index.js:15:1)
Cela est particulièrement utile avec du code inconnu lorsque l’on ne sait pas d’où une fonction est appelée : ajoutez un console.trace(), exécutez le scénario, et le chemin complet apparaîtra. Supprimez ces appels avant de livrer le produit.
Donnez des noms à vos fonctions
Les fonctions véritablement anonymes apparaissent sous la forme <anonymous> dans les traces, ce qui ne vous laisse presque rien sur quoi chercher. Les exemples ci-dessous illustrent la différence, bien que le premier nécessite une correction.
const doSomething = () => {
throw new Error("broken");
};
doSomething();
Error: broken
at <anonymous> (/box/index.js:2:9) <-- useless for debugging
Dans les moteurs actuels, une fonction flèche assignée directement à une variable const voit son nom déduit de cette variable, de sorte que cet exemple précis affiche en réalité at doSomething plutôt que <anonymous>. L’indication peu utile <anonymous> provient des callbacks en ligne passés directement à des fonctions comme map, then ou des écouteurs d’événements. Une déclaration nommée est sans équivoque :
function doSomething() {
throw new Error("broken");
}
doSomething();
Error: broken
at doSomething (/box/index.js:2:9) <-- instantly tells you what crashed
Dans une base de code pleine d’appels en ligne de commande, des dizaines de cadres <anonymous> identiques rendent le suivi des traces fastidieux. Donner un nom aux appels ou les extraire dans des fonctions nommées permet de garder les traces lues facilement.
Traces d’erreur Async/await et outils de production dans V8 et Node.js
Pour les services backend et les grandes applications frontend, V8 fournit des API pour contrôler la manière dont les traces sont capturées.
Capter plus de cadres avec Error.stackTraceLimit
Par défaut, V8 enregistre au maximum 10 cadres dans error.stack. Dans des codes très hiérarchisés, tels que les pipelines de requêtes d’Express ou de NestJS ou les chaînes de middleware de Redux, ces dix cadres peuvent être consommés par le code du framework avant que vos propres fonctions n’apparaissent. Vous pouvez augmenter cette limite de manière globale :
// Capture up to 50 frames for deep diagnostics
Error.stackTraceLimit = 50;
// Or capture everything without any limit:
// Error.stackTraceLimit = Infinity;
Cette configuration ne contrôle que le nombre de frames enregistrés dans le trace. Elle n’a aucun effet sur la profondeur maximale réelle de la pile, et des valeurs très élevées, en particulier Infinity, rendent la création de chaque erreur plus coûteuse ; il est donc préférable d’utiliser un nombre modéré en environnement de production.
Erreurs personnalisées plus propres avec Error.captureStackTrace
Lorsque vous définez vos propres classes d’erreur, le constructeur lui-même apparaîtrait normalement dans le trace. Error.captureStackTrace vous permet de l’exclure, de sorte que le trace commence là où l’erreur a réellement été levée :
class DatabaseError extends Error {
constructor(message) {
super(message);
this.name = "DatabaseError";
// Omit the constructor call itself from the stack trace
if (Error.captureStackTrace) {
Error.captureStackTrace(this, DatabaseError);
}
}
}
Le deuxième argument indique à V8 d’omettre cette fonction ainsi que tous les frames qui la précèdent. La condition if permet à la classe de fonctionner dans des moteurs qui ne disposent pas de cette API spécifique à V8.
Traces d’erreur asynchrones avec async/await
Historiquement, le code asynchrone produisait des traces tronquées. Un callback provenant de setTimeout ou une promesse s’exécute sur une pile nouvelle longtemps après que l’appelant initial ait renvoyé, ce qui fait disparaître la chaîne des appels.
Le V8 moderne, tant dans Chrome que dans Node.js, offre des traces d’erreur asynchrones sans coût supplémentaire pour async/await. Lorsqu’un événement est généré après que l’instruction await a repris son exécution, V8 reconstitue la chaîne des fonctions en attente uniquement lorsque c’est vraiment nécessaire, en marquant ces frames avec async :
Error: Query failed
at executeQuery (db.js:14:11)
at async getUserData (user.js:22:9)
at async handleRequest (server.js:45:5)
Cette avantage s’applique aux constructeurs natifs async/await. Les chaînes longues de fonctions .then() écrites manuellement ainsi que le code basé sur des callbacks ne bénéficient pas de cette même reconstruction, ce qui constitue une raison de plus de privilégier await dans les codes que l’on compte déboguer.
La pile d’appels en quelques règles
- JavaScript exécute votre code sur un seul thread. L’environnement hôte gère les tâches en arrière-plan de manière concurrente, mais chaque callback généré attend son tour sur ce thread.
- La pile d’appels est une structure LIFO petite, rapide et de taille fixe qui enregistre quelle fonction est en cours d’exécution et où chaque appelant reprendra son exécution ; les objets et les closures résident dans la mémoire heap et sont référencés depuis la pile.
- Une appelation ajoute un cadre à la pile tandis qu’un retour le retire. Le cadre en haut de la pile s’exécute toujours, tandis que tous les cadres en dessous sont suspendus.
- Un débordement de pile, signalé sous la forme
RangeError: Maximum call stack size exceeded, signifie que le budget mémoire de la pile est épuisé, généralement à cause d’une récursivité sans cas de base atteignable, que ce soit directement ou indirectement. - Pour les récursions profondes, privilégiez les boucles ou les mécanismes de saut, et évitez de transmettre des tableaux très volumineux dans les appels de fonction. Les appels de queue correctement gérés ne sont disponibles que dans Safari, donc ne comptez pas sur eux.
- Dans DevTools, les commandes Step Over, Step Into et Step Out correspondent à l’exécution d’un appel, à l’entrée dans un cadre et à la sortie d’un cadre.
- Un historique de pile est la liste des états de la pile au moment de la panne : le sommet indique l’endroit où l’erreur est survenue, le fond représente le point d’entrée, et la cause se trouve souvent un ou deux cadres en dessous du sommet.
console.trace(), les fonctions nommées,Error.stackTraceLimit,Error.captureStackTraceainsi que les constructeurs natifsasync/awaitrendent tous les traces plus informatives.- Les fonctions de rappel en file d’attente, comme les gestionnaires de
setTimeout, ne s’exécutent que lorsque la pile d’appels a été entièrement vidée, c’est pourquoi la pile d’appels constitue la base pour comprendre le JavaScript asynchrone.
Pourquoi cela est avantageux
Beaucoup des bugs qui prennent des heures à résoudre sont en réalité des problèmes liés à la pile : une fonction qui renvoie plus tôt que prévu, un callback qui s’exécute dans un ordre inattendu, une valeur undefined alors qu’elle ne devrait pas l’être. Les développeurs qui résolvent ces problèmes rapidement ne sont pas plus intelligents ; ils ont simplement appris la structure de la pile et de ses traces une fois pour toutes, ce qui leur permet d’aller directement au cadre pertinent. Une trace de pile est un enregistrement mécanique de ce qui s’est réellement exécuté, et une fois qu’on lui fait confiance, le débogage devient considérablement moins pénible.