Retour au cours

frontend / javascript

Event loop : microtasks vs macrotasks

Leçon 131 exercice

Explication

Ce que vous allez apprendre

  • Décrire les rôles respectifs de la pile d'appels, de la file des microtasks et de celle des macrotasks
  • Prédire l'ordre d'affichage d'un mélange de code synchrone, setTimeout et Promises
  • Expliquer pourquoi setTimeout(fn, 0) s'exécute toujours après les microtasks en attente
  • Comprendre pourquoi await découpe une fonction en morceaux planifiés comme des microtasks
  • Déboguer un ordre d'exécution asynchrone qui semble incohérent au premier abord

Dans quel contexte ?

Lors d'une session de debug, un développeur ne comprend pas pourquoi son message de log "chargement terminé" (affiché après un setTimeout(fn, 0)) apparaît systématiquement APRÈS le message "données reçues" d'un fetch déjà résolu, alors que le setTimeout a été placé avant dans le code et a un délai de zéro milliseconde. La réponse tient dans les règles précises de l'event loop, qui vide entièrement la file des microtasks (dont fait partie une Promise résolue) avant de traiter la moindre macrotask comme setTimeout.

1. La pièce qui manquait encore

Après trois leçons sur les callbacks, les Promises et async/await, il manque une pièce essentielle : comprendre PRÉCISÉMENT dans quel ordre tout ce code asynchrone s'exécute réellement. C'est le rôle de l'event loop.

Prérequis

Cette leçon suppose une bonne compréhension des trois leçons précédentes (callbacks, Promises, async/await). Sans elles, les notions de microtask et macrotask n'auront pas de socle concret sur lequel s'appuyer.

2. Un seul thread, donc des files d'attente

Puisque JavaScript n'a qu'un seul thread, il ne peut jamais exécuter deux morceaux de code en même temps : il les met en attente dans des files, puis les traite selon des règles précises.

3. La règle de base : synchrone d'abord

Le code synchrone s'exécute toujours en premier, intégralement, jusqu'à ce que la pile d'appels soit totalement vide. Rien d'asynchrone ne peut s'intercaler avant que cette pile ne soit vidée.

4. Ensuite, deux files bien distinctes

Une fois la pile vide, l'event loop vide TOUTE la file des "microtasks" (callbacks de Promises, code après un await) avant de traiter une seule tâche de la file des "macrotasks" (setTimeout, événement utilisateur) — puis le cycle recommence.

FileContientPriorité
Pile d'appelsCode synchrone en coursToujours en premier
Microtasks.then(), code après await, queueMicrotaskToutes vidées avant la macrotask suivante
MacrotaskssetTimeout, setInterval, événements DOMUne seule traitée par tour de boucle

5. Le phénomène qui déroute presque tous les débutants

Cette règle explique pourquoi setTimeout(fn, 0) ne s'exécute jamais avant une Promise déjà résolue, même avec un délai nul : les microtasks passent systématiquement en priorité sur les macrotasks.

Piège fréquent

console.log("A"); setTimeout(() => console.log("B"), 0); Promise.resolve().then(() => console.log("C")); console.log("D"); affiche A, D, C, B — jamais A, D, B, C. Le synchrone (A, D) passe d'abord, puis toutes les microtasks (C), et enfin la macrotask (B).

6. Ce que fait vraiment await

Ce même mécanisme explique pourquoi un await "coupe" une fonction en deux morceaux : ce qui suit l'await devient littéralement une microtask, planifiée pour plus tard, même si la Promise est déjà résolue au moment de l'appel.

Cette leçon est une des plus denses du cours, mais elle est la clé pour déboguer sereinement n'importe quel ordre d'exécution surprenant en JavaScript.

Commandes & code

Event loop : microtasks vs macrotasks

Le mécanisme qui explique l'ordre EXACT d'exécution du code asynchrone.

js
// JS est mono-thread : UNE seule pile d'execution (call stack).
// L'event loop orchestre : call stack -> microtasks -> rendu -> macrotasks -> (repeter)

console.log("1 - synchrone");

setTimeout(() => console.log("2 - macrotask (setTimeout)"), 0);

Promise.resolve().then(() => console.log("3 - microtask (Promise)"));

console.log("4 - synchrone");

// Ordre d'affichage : 1, 4, 3, 2
// -- le code synchrone s'execute TOUJOURS en premier (jusqu'a la pile vide)
// -- puis TOUTES les microtasks en attente sont videes
// -- puis UNE SEULE macrotask est executee, avant de revider les microtasks, etc.

// --- Categorisation des taches ---
// Microtasks : Promise.then/catch/finally, queueMicrotask, async/await (apres un await)
// Macrotasks : setTimeout, setInterval, setImmediate (Node), I/O, evenements UI

// --- Demonstration : les microtasks sont TOUJOURS videes avant la macrotask suivante ---
setTimeout(() => console.log("macrotask A"), 0);
Promise.resolve()
    .then(() => console.log("microtask 1"))
    .then(() => console.log("microtask 2"))
    .then(() => console.log("microtask 3"));
setTimeout(() => console.log("macrotask B"), 0);
// Ordre : microtask 1, microtask 2, microtask 3, macrotask A, macrotask B
// (meme si les 3 .then() sont chaines, ils passent AVANT n'importe quelle macrotask)

// --- queueMicrotask : planifier explicitement une microtask ---
queueMicrotask(() => console.log("microtask explicite"));

// --- async/await et microtasks : le code apres "await" est planifie comme une microtask ---
async function demo() {
    console.log("A - avant await");
    await null;                       // point de suspension : le code suivant devient une microtask
    console.log("C - apres await");    // s'execute APRES le code synchrone qui suit l'appel
}
demo();
console.log("B - juste apres l'appel de demo()");
// Ordre : A, B, C -- car "await null" cede la main, et "C" attend que le call stack soit vide

// --- Piege classique : une microtask qui en cree une autre bloque le rendu / les macrotasks ---
function boucleMicrotaskInfinie() {
    Promise.resolve().then(boucleMicrotaskInfinie);    // starve l'event loop : JAMAIS de macrotask executee
}
// A EVITER absolument en production : gele l'UI et empeche tout setTimeout de s'executer

// --- requestAnimationFrame : execute avant le rendu du navigateur, ni micro ni macrotask classique ---
requestAnimationFrame(() => console.log("avant le prochain repaint"));

// --- Node.js : process.nextTick a une priorite ENCORE plus haute que les Promises ---
// (specifique a Node, n'existe pas dans le navigateur)
// process.nextTick(() => console.log("s'execute avant TOUTES les microtasks Promise"));

// --- Cas pratique : pourquoi ceci surprend souvent les debutants ---
console.log("debut");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise 1"));
Promise.resolve().then(() => {
    console.log("promise 2");
    Promise.resolve().then(() => console.log("promise imbriquee"));  // ajoutee a la queue en cours de vidage
});
console.log("fin");
// Ordre : debut, fin, promise 1, promise 2, promise imbriquee, timeout

Résumé

  • L'event loop exécute : tout le code synchrone, puis TOUTES les microtasks, puis UNE macrotask, en boucle.
  • Promise.then/catch/finally et await (après le point de suspension) sont des microtasks, prioritaires sur setTimeout.
  • Une chaîne de microtasks qui s'auto-régénère indéfiniment bloque totalement les macrotasks et le rendu UI.
  • Ce modèle explique pourquoi setTimeout(fn, 0) ne s'exécute jamais avant les Promises déjà résolues.

Exercices pratiques

1 disponible
1

Mission : prédire l'ordre exact des logs avant de lancer le debugger

Objectif : Tracer précisément l'ordre d'exécution d'un mélange de code synchrone, setTimeout, Promises et await, sans exécuter le code.

Contexte

Un développeur de Technologik ne comprend pas pourquoi son log "chargement terminé" (placé dans un setTimeout(fn, 0)) apparaît toujours après "données reçues" (résultat d'une Promise déjà résolue), alors que le setTimeout est écrit avant dans le fichier. Avant de lui répondre, tu dois être capable de tracer mentalement, sans l'exécuter, l'ordre exact d'affichage du code suivant :

js
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
async function f() {
  console.log("4");
  await null;
  console.log("5");
}
f();
console.log("6");
Résoudre l’exercice →