frontend / javascript
Event loop : microtasks vs macrotasks
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,
setTimeoutet Promises - Expliquer pourquoi
setTimeout(fn, 0)s'exécute toujours après les microtasks en attente - Comprendre pourquoi
awaitdé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.
| File | Contient | Priorité |
|---|---|---|
| Pile d'appels | Code synchrone en cours | Toujours en premier |
| Microtasks | .then(), code après await, queueMicrotask | Toutes vidées avant la macrotask suivante |
| Macrotasks | setTimeout, setInterval, événements DOM | Une 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 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, timeoutRésumé
- L'event loop exécute : tout le code synchrone, puis TOUTES les microtasks, puis UNE macrotask, en boucle.
Promise.then/catch/finallyetawait(après le point de suspension) sont des microtasks, prioritaires sursetTimeout.- 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
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 :
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");