Async JavaScript - 01 - La Call Stack

Comment JavaScript exécuté le code de manière synchrone avec la pile d'appels, les stack frames, et le stack overflow.

La Call Stack

Ce que tu vas apprendre

  • Comment la pile d'appels (call stack) fonctionne en JavaScript
  • Ce que sont les stack frames et comment elles s'empilent/depilent
  • Pourquoi une recursion infinie provoque un stack overflow
  • Ce qu'est la tail call optimization et pourquoi on n'en profite (presque) pas

Prerequisites


Avant de parler d'asynchrone, il faut comprendre comment JavaScript exécuté du code synchrone. Parce que tout le modèle async repose sur un principe simple : le thread principal ne fait qu'une seule chose a la fois, et il utilise une pile pour savoir ou il en est.

La pile d'appels, c'est quoi ?

Imagine une pile d'assiettes. Tu poses une assiette dessus (push), tu retires celle du dessus (pop). Jamais celle du milieu. C'est exactement comme ca que fonctionne la call stack de JavaScript.

Chaque fois que tu appelles une fonction, le moteur JS créé un stack frame -- un bloc de mémoire qui contient les arguments de la fonction, ses variables locales, et l'adresse de retour. Ce frame est pousse sur la pile. Quand la fonction termine (return explicite ou implicite), le frame est depile.

javascriptfunction multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  const result = square(n);
  console.log(result);
}

printSquare(4);

Voici ce qui se passe dans la pile, étape par étape :

Etape 1:          Etape 2:          Etape 3:          Etape 4:
                                    +-----------+
                  +-----------+     | multiply  |     +-----------+
+-----------+     | square    |     | (4, 4)    |     | square    |
| printSquare|    | (4)       |     +-----------+     | -> 16     |
| (4)        |    +-----------+     | square    |     +-----------+
+-----------+     | printSquare|    | (4)       |     | printSquare|
| global    |     | (4)        |    +-----------+     | (4)        |
+-----------+     +-----------+     | printSquare|    +-----------+
                  | global    |     | (4)        |    | global    |
                  +-----------+     +-----------+     +-----------+
                                    | global    |
                                    +-----------+

Etape 5:          Etape 6:          Etape 7:
+-----------+
| console.log|
| (16)       |
+-----------+
| printSquare|    +-----------+
| (4)        |    | printSquare|
+-----------+     | -> done    |
| global    |     +-----------+     +-----------+
+-----------+     | global    |     | global    |
                  +-----------+     +-----------+

C'est mecanique. Previsible. Et ca explique pourquoi JavaScript ne peut pas exécuter deux fonctions "en meme temps" -- il n'y a qu'une seule pile.

Anatomie d'un stack frame

Un stack frame contient :

  • La référencé de la fonction appelee
  • Les arguments passes a l'appel
  • Les variables locales declarees dans la fonction
  • L'adresse de retour (ou reprendre dans la fonction appelante)
javascriptfunction greet(name) {
  const greeting = "Salut " + name;  // variable locale
  return greeting;                     // adresse de retour -> appelant
}

// Frame cree :
// {
//   function: greet,
//   args: { name: "Alice" },
//   locals: { greeting: "Salut Alice" },
//   returnAddress: <ligne suivante dans l'appelant>
// }

En pratique, les moteurs JS modernes (V8, SpiderMonkey) optimisent tout ca en interne. Mais le modèle mental reste valide.

Stack overflow : quand la pile deborde

La pile a une taille limitee. Chaque environnement definit sa propre limite -- en général entre 10 000 et 25 000 frames selon le navigateur et la taille des frames.

La facon la plus simple de provoquer un stack overflow :

javascriptfunction oops() {
  oops(); // appel recursif sans condition d'arret
}

oops();
// RangeError: Maximum call stack size exceeded

Chaque appel a oops() empile un nouveau frame, sans jamais en depiler un. La pile grandit jusqu'a atteindre la limite, et le moteur leve une erreur.

J'ai vu ce bug en production une fois. Un composant React sur paltemps.fr qui se re-renderait a cause d'un useEffect sans tableau de dépendances qui modifiait l'état. Le navigateur affichait un ecran blanc. Pas d'erreur visible pour l'utilisateur -- juste un stack overflow silencieux dans la console.

+-----------+
| oops()    |  <- frame #10001 : BOOM
+-----------+
| oops()    |  <- frame #10000
+-----------+
|   ...     |
+-----------+
| oops()    |  <- frame #2
+-----------+
| oops()    |  <- frame #1
+-----------+
| global    |
+-----------+

Recursion correcte vs recursion folle

La recursion n'est pas mauvaise en soi. Il faut juste une condition d'arrêt :

javascriptfunction factorial(n) {
  if (n <= 1) return 1;    // condition d'arret
  return n * factorial(n - 1);
}

factorial(5);
// pile max : 5 frames (pour n=5,4,3,2,1)
// resultat : 120

Mais meme avec une condition d'arrêt, si n est trop grand (par exemple 100 000), tu auras quand meme un stack overflow. La pile ne fait pas la différence entre une recursion "correcte" et une recursion "infinie" -- elle voit juste des frames qui s'empilent.

Tail Call Optimization (TCO)

ES2015 a introduit la tail call optimization. L'idee : si le dernier acte d'une fonction est d'appeler une autre fonction (un "tail call"), le moteur peut réutiliser le frame courant au lieu d'en créer un nouveau.

javascript// Pas un tail call : on multiplie APRES l'appel recursif
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1); // doit garder n en memoire
}

// Tail call : rien a faire apres l'appel recursif
function factorialTCO(n, acc = 1) {
  if (n <= 1) return acc;
  return factorialTCO(n - 1, n * acc); // dernier acte = appel
}

La mauvaise nouvelle : en 2026, seul Safari implemente TCO. V8 (Chrome, Node.js) et SpiderMonkey (Firefox) ne l'ont jamais implemente. Ca reste dans la spec, mais en pratique, tu ne peux pas compter dessus.

Si tu as besoin de recursion profonde, transforme-la en boucle iterative. C'est moins elegant, mais ca ne plantera pas.

La call stack et l'async

Voici le point qui relie tout a la suite de cette serie : l'event loop ne peut exécuter un callback que quand la call stack est vide.

javascriptconsole.log("debut");

setTimeout(function cb() {
  console.log("timeout");
}, 0);

console.log("fin");

// Affiche : debut, fin, timeout

Meme avec un delai de 0ms, le callback cb ne s'exécuté pas immédiatement. Il est place dans la file des macrotasks. L'event loop attend que la pile soit vide (apres console.log("fin")) avant de l'exécuter.

On verra ca en détail dans l'article sur l'event loop. Pour l'instant, retiens juste : pile vide = condition nécessaire pour que l'async se produise.

Résumé

  • La call stack est une pile LIFO (Last In, First Out) qui trace l'exécution des fonctions
  • Chaque appel de fonction créé un stack frame (arguments, variables locales, adresse de retour)
  • Un stack overflow se produit quand la pile dépassé sa limite (recursion sans fin ou trop profonde)
  • La tail call optimization existe dans la spec ES2015, mais seul Safari l'implemente
  • L'event loop attend que la call stack soit vide pour exécuter les callbacks asynchrones

Precedent : Introduction | Suivant : L'Event Loop

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.