Menu
Français

Event Loop JavaScript : comment tourne l'async

Le modèle mental de l'asynchrone en JavaScript : la call stack, la task queue, la file de microtâches, et le rôle de chef d'orchestre de l'event loop.

Mono-thread, mais pas séquentiel

JavaScript tourne sur un seul thread. Il n'y a qu'une seule call stack, et à un instant donné, une seule fonction s'exécute. Deux lignes de votre code ne tournent jamais en parallèle dans le même realm.

Ça paraît limitant, jusqu'à ce qu'on se rappelle ce que fait réellement JavaScript : récupérer des données, attendre les clics de l'utilisateur, lire des fichiers. L'essentiel du « travail », c'est de l'attente. Et c'est précisément là qu'intervient l'event loop : cette mécanique rend l'attente peu coûteuse. Votre code confie une tâche au navigateur ou à Node, repart vaquer à ses occupations, puis est notifié quand le résultat est prêt.

index.js
Output
Click Run to see the output here.

premier et deuxième s'affichent dans l'ordre. troisième arrive ensuite — même avec un timeout à 0. Ce décalage, c'est la boucle événementielle qui fait son boulot, et comprendre pourquoi, c'est tout l'intérêt de cette page.

La pile d'appels (call stack)

Chaque appel de fonction empile une frame sur la call stack. Quand la fonction retourne, sa frame est dépilée. La pile, c'est juste une pile : dernier entré, premier sorti.

index.js
Output
Click Run to see the output here.

Quand outer() s'exécute, Node empile outer, puis inner, dépile inner dès qu'il retourne "done", puis dépile outer. La pile est de nouveau vide. Et c'est précisément ce moment de « pile vide » que la boucle événementielle guette.

Le code synchrone s'exécute du début à la fin sur la pile, sans interruption possible par quoi que ce soit d'asynchrone. Si tu écris une boucle while (true), la pile ne se vide jamais et la page se fige — plus de clics, plus de timers, plus de callbacks de promesses. La boucle événementielle n'a rien à faire, tout simplement parce qu'elle n'arrive jamais à prendre la main.

Où vit réellement le code asynchrone

JavaScript en lui-même ne sait pas faire une requête réseau ni attendre 100 millisecondes. Ces API appartiennent à l'environnement hôte — le navigateur ou Node. Quand tu appelles setTimeout(fn, 100), voici ce qui se passe concrètement :

  1. Le timer est enregistré auprès de l'hôte.
  2. setTimeout retourne immédiatement. La pile continue son exécution.
  3. 100 ms plus tard, l'hôte place fn dans une file d'attente.
  4. Dès que la pile est vide, la boucle événementielle sort fn de la file et l'exécute.
index.js
Output
Click Run to see the output here.

Le callback du timer ne peut pas s'exécuter tant que la boucle for et le console.log("fin") ne sont pas terminés — tout simplement parce que la pile n'est pas encore vide. Les timers imposent un délai minimum, pas une garantie.

Deux files d'attente : tâches et microtâches

Il n'existe pas qu'une seule file, mais bien deux. Cette distinction explique la plupart des comportements surprenants de la boucle événementielle en JavaScript.

  • File des tâches (aussi appelée file des macrotâches) : setTimeout, setInterval, les callbacks d'I/O, les événements de l'interface.
  • File des microtâches : les callbacks de promesses (.then, .catch, .finally), les reprises après await, et tout ce qui est planifié via queueMicrotask.

Voici la règle que suit l'event loop :

  1. Exécuter une tâche de la file des tâches.
  2. Vider entièrement la file des microtâches — toutes les microtâches, y compris celles ajoutées pendant le vidage.
  3. Effectuer le rendu si nécessaire (dans les navigateurs).
  4. Retourner à l'étape 1.

Les microtâches passent toujours avant la tâche suivante. Et c'est précisément ce qui déroute tant de développeurs :

index.js
Output
Click Run to see the output here.

Ordre d'affichage : sync 1, sync 2, promise, timeout. Le code synchrone s'exécute en premier. Ensuite, la pile se vide. Puis la boucle événementielle vide toute la file de microtâches (promise). Et c'est seulement après que la tâche du timer (timeout) est prise en charge.

Quand les microtâches affament les tâches

Comme la file de microtâches est entièrement vidée avant de passer à la tâche suivante, une microtâche qui ne cesse d'en planifier d'autres bloquera la file des tâches indéfiniment :

index.js
Output
Click Run to see the output here.

Le timer ne se déclencherait jamais, puisque chaque microtâche en empile une nouvelle, et la boucle ne laisse jamais la file se vider. Les chaînes de promesses ne posent pas ce problème, car chaque .then ne programme qu'une seule continuation — en revanche, les boucles de microtâches écrites à la main sont un piège classique à bien avoir en tête.

await : du sucre syntaxique pour une microtâche

Quand tu fais await sur une promesse, la fonction se met en pause et tout ce qui suit est planifié comme une microtâche, qui s'exécutera une fois la promesse résolue. Rien de magique là-dedans — c'est simplement un .then déguisé.

index.js
Output
Click Run to see the output here.

Résultat : A, C, B. Le await rend la main à l'appelant. console.log("C") s'exécute sur la pile courante. Ensuite, la file de microtâches se vide et la suite de demo reprend, affichant B.

Garde ça en tête quand tu lis du code asynchrone. await ne bloque pas — il cède la main.

Un exemple complet : remettre tout dans l'ordre

Assemblons toutes les pièces du puzzle :

index.js
Output
Click Run to see the output here.

Ordre d'exécution :

  1. 1: sync — s'exécute sur la pile.
  2. 6: sync — toujours sur la pile.
  3. La pile se vide. La file de microtâches se vide à son tour : 3: promise, 5: microtask, puis 4: nested microtask (planifiée pendant le drainage, elle est quand même prise en compte).
  4. La tâche suivante démarre : 2: timeout.

Résultat final : 1, 6, 3, 5, 4, 2. Si tu arrives à suivre ce raisonnement, c'est que tu as compris la boucle événementielle.

Node a plus de phases

La boucle événementielle de Node est une version étendue du modèle du navigateur. Elle comporte plusieurs phases distinctes — timers, callbacks d'E/S en attente, poll, check, close — et les microtâches se vident entre chaque phase. setImmediate tourne dans la phase check, alors que process.nextTick passe avant les microtâches classiques (il dispose de sa propre file, encore plus prioritaire).

Pas besoin de connaître ce schéma de phases par cœur dès le premier jour. L'idée à retenir est la même que dans le navigateur : le code synchrone s'exécute jusqu'au bout, puis les microtâches se vident, et ensuite la boucle s'occupe du callback suivant dans la file.

Pourquoi c'est important

Une fois le modèle assimilé, beaucoup de code asynchrone cesse d'être mystérieux :

  • Une boucle for trop longue fige ton interface parce que la boucle événementielle n'a pas l'occasion de reprendre la main.
  • setTimeout(fn, 0) est une façon de reporter du travail jusqu'à la fin de la tâche courante et des microtâches.
  • Un callback .then qui se déclenche « immédiatement » après une promesse déjà résolue doit quand même attendre que le code synchrone en cours se termine.
  • await dans une boucle sérialise le travail, car chaque itération cède la main à la file de microtâches avant de continuer.

Déboguer du code asynchrone revient surtout à se demander : « qu'est-ce qu'il y a sur la pile en ce moment, et qu'est-ce qui est en file d'attente ? ». La boucle événementielle est la réponse.

La suite : les callbacks

Avant les promesses et async/await, le seul outil dont disposait JavaScript pour gérer l'asynchrone, c'était le callback — une fonction qu'on confie à une API pour qu'elle l'appelle plus tard. On en trouve encore partout (écouteurs d'événements, APIs de base de Node), et les comprendre, c'est poser les fondations de tout ce qui suit dans ce chapitre.

Questions fréquentes

C'est quoi l'event loop en JavaScript ?

C'est le mécanisme qui permet à JavaScript, pourtant mono-thread, d'exécuter du code asynchrone sans bloquer. La boucle surveille la call stack : dès qu'elle est vide, elle y place le prochain callback en attente. Les timers, les I/O et les suites de promesses finissent tous dans des files que l'event loop vide une tâche à la fois.

Pourquoi JavaScript est-il mono-thread ?

La spec du langage définit une seule call stack par realm, donc ton code tourne forcément sur un seul thread. La concurrence vient de l'hôte (le navigateur ou Node), qui délègue le boulot à des API en arrière-plan — timers, réseau, I/O fichier — et met un callback en file d'attente quand c'est terminé. Tu ne verras jamais deux bouts de JS s'exécuter en même temps dans le même contexte.

Quelle différence entre microtasks et macrotasks ?

Les microtasks viennent des promesses (.then, await) et de queueMicrotask. Les macrotasks viennent de setTimeout, setInterval, des I/O et des événements UI. Après chaque macrotask, l'event loop vide toute la file de microtasks avant de passer à la macrotask suivante — c'est pour ça qu'un Promise.resolve().then(...) s'exécute toujours avant un setTimeout(..., 0) planifié au même instant.

Pourquoi setTimeout avec 0ms ne s'exécute pas immédiatement ?

setTimeout(fn, 0) ne veut pas dire « exécute maintenant », mais « mets fn en file comme macrotask, au plus tôt dans 0ms ». Le code synchrone en cours doit d'abord terminer, la file de microtasks doit se vider, et c'est seulement après que l'event loop ira chercher ton callback de timer. Le 0, c'est un minimum, pas une garantie.

Apprendre à coder avec Coddy

COMMENCER