Retour au blog
juin 2026·4 min de lectureGénéré par IA
Langue:DeutschEnglishFrançaisÖsterreichischKlingonian Version originale: English

Comprendre la boucle d'événements JavaScript

#javascript#async#performance

JavaScript s'exécute sur un seul thread, et pourtant il gère des milliers de requêtes réseau, de timers et d'événements d'interface simultanés sans bloquer. La boucle d'événements est le mécanisme qui rend cela possible, et une fois que vous savez prédire son ordre d'exécution, toute une catégorie de bugs du type « pourquoi ceci s'est-il exécuté avant cela ? » cesse d'être mystérieuse.

Un thread, une pile d'appels

JavaScript ne peut exécuter qu'un seul morceau de code à la fois. Chaque appel de fonction empile un cadre sur la pile d'appels, et la pile doit se vider avant que le moteur regarde autre chose. Jusque-là, tout le monde sait. Ce qui déroute, c'est tout ce qui se passe autour de la pile : les timers, les promesses, les callbacks d'E/S et les événements du DOM ne s'exécutent pas immédiatement quand ils sont prêts. Ils sont mis en file, et c'est la boucle d'événements qui décide quand les en sortir pour les exécuter.

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

Cela affiche 1, 4, 3, 2, et non 1, 2, 3, 4. Pour comprendre pourquoi, il faut savoir qu'il n'y a pas une file, mais (au moins) deux.

Deux files, pas une

Les macrotâches (aussi appelées « file de tâches ») comprennent setTimeout, setInterval, les callbacks d'E/S et les événements d'interface. Les microtâches comprennent les callbacks de promesses (.then, .catch, .finally) et queueMicrotask.

La règle de la boucle d'événements : une fois que le script en cours est terminé et que la pile d'appels est vide, on vide toute la file de microtâches (y compris les nouvelles microtâches ajoutées pendant ce vidage) avant d'exécuter ne serait-ce qu'une macrotâche.

C'est pourquoi 3 s'est affiché avant 2 plus haut : Promise.resolve().then(...) met une microtâche en file, setTimeout(..., 0) met une macrotâche en file, et les microtâches gagnent toujours, quelle que soit la petitesse du délai de la macrotâche.

function logOrder() {
  console.log('start');

  setTimeout(() => console.log('macrotask'), 0);

  Promise.resolve()
    .then(() => console.log('microtask 1'))
    .then(() => console.log('microtask 2'));

  console.log('end');
}

logOrder();
// start
// end
// microtask 1
// microtask 2
// macrotask

Les deux appels .then() chaînés s'exécutent avant le callback de setTimeout, même s'il a été planifié avec un délai de 0 ms : la file de microtâches est entièrement vidée en premier.

Pourquoi setTimeout(fn, 0) ne veut pas dire « maintenant »

Un délai de 0 ne signifie pas « exécuter immédiatement ». Il signifie « exécuter dès que possible, mais seulement une fois la pile d'appels vide et la file de microtâches vidée ». Les navigateurs limitent aussi les timeouts imbriqués à un minimum de 4 ms après quelques niveaux d'imbrication. Utiliser setTimeout(fn, 0) pour « différer » un travail est acceptable : n'attendez simplement pas qu'il s'intercale avec du code synchrone ou du code de microtâche.

Un cas pratique : pourquoi la mise à jour de l'interface arrive en retard

function expensiveWork() {
  let sum = 0;
  for (let i = 0; i < 1e9; i++) sum += i;
  return sum;
}

showLoadingSpinner(); // updates the DOM
const result = expensiveWork(); // blocks the thread
hideLoadingSpinner();

Le spinner n'apparaît jamais vraiment. showLoadingSpinner() planifie une mise à jour du DOM, mais le navigateur ne peut pas peindre avant que la pile d'appels soit vide, et expensiveWork() la garde occupée tout le temps. La solution consiste à rendre la main à la boucle d'événements avant le travail lourd, afin que le navigateur ait une chance de peindre :

function showSpinnerThenWork() {
  showLoadingSpinner();

  setTimeout(() => {
    const result = expensiveWork();
    hideLoadingSpinner();
    renderResult(result);
  }, 0);
}

Désormais, la mise à jour du spinner est peinte avant que expensiveWork() ne démarre. Pour un travail réellement long, une meilleure solution est de le sortir complètement du thread principal avec un Web Worker : setTimeout(fn, 0) n'aide que pour le premier rendu, il n'empêche pas la boucle de se bloquer à nouveau dès que expensiveWork() s'exécute.

async/await est du sucre syntaxique pour les microtâches

Les fonctions async n'introduisent aucune nouvelle primitive de concurrence : elles reposent entièrement sur les promesses et la file de microtâches. Chaque await suspend la fonction et planifie sa reprise comme microtâche une fois la valeur attendue résolue.

async function sequence() {
  console.log('a');
  await null;
  console.log('b');
}

console.log('before');
sequence();
console.log('after');

// before
// a
// after
// b

await null rend tout de même la main à la file de microtâches, bien que null soit déjà une valeur « résolue » : la suite après await ne s'exécute jamais de façon synchrone, quoi que vous attendiez.

Affamer la boucle d'événements avec des microtâches

Comme la file de microtâches est entièrement vidée avant la macrotâche suivante, une microtâche qui planifie sans cesse d'autres microtâches peut affamer indéfiniment le rendu et les E/S :

function recurse() {
  Promise.resolve().then(recurse);
}
recurse(); // freezes the tab — the loop never reaches a macrotask

C'est un vrai piège dans les chaînes de promesses récursives : un callback .then() qui se remet en file sans condition bloquera la page, alors que l'équivalent avec setTimeout laisserait au moins le navigateur respirer entre les itérations.

À retenir

L'ordre de la boucle d'événements n'a rien d'arbitraire : le code synchrone s'exécute d'abord, puis toute la file de microtâches est vidée, puis une macrotâche s'exécute, puis la file de microtâches est de nouveau vidée, et ainsi de suite. Les promesses et async/await vivent sur la voie des microtâches ; les timers, les E/S et les événements d'interface sur celle des macrotâches. Une fois que vous savez sur quelle voie se trouve un morceau de code, vous pouvez prédire son ordre d'exécution, sans plus avoir à deviner.

Plus récentÉcrire votre première GitHub ActionPlus ancienDéboguer les fuites mémoire dans Node.js