Async JavaScript - 04 - Les Microtasks

La file des microtasks, Promise.then, queueMicrotask, MutationObserver, et le risque de starvation.

Les Microtasks

Ce que tu vas apprendre

  • Ce qu'est la file des microtasks et ce qui la differencie des macrotasks
  • Quelles APIs creent des microtasks (Promise.then, queueMicrotask, etc.)
  • Pourquoi les microtasks sont drainee entièrement avant le prochain macrotask
  • Le danger de la microtask starvation

Prerequisites


Si les macrotasks sont comme une file d'attente au supermarche -- tu prends un ticket, tu attends ton tour -- les microtasks, c'est plutot le VIP qui passe devant tout le monde. Chaque. Fois.

Et c'est exactement pour ca qu'elles sont puissantes et dangereuses a la fois.

La file des microtasks

La file des microtasks (ou "microtask queue") est une file séparée de celle des macrotasks. Et elle a une propriété fondamentale : elle est drainee entièrement avant que l'event loop passe au macrotask suivant ou au rendu.

                    Event Loop

  [Macrotask] --> Execute --> Drain ALL microtasks --> Rendu
       ^                                               |
       +-----------------------------------------------+

  File macrotasks: [task1] [task2] [task3]

  File microtasks: [micro1] [micro2]
                   ^                ^
                   |                |
                   +-- TOUTES executees avant task2

Les APIs qui creent des microtasks :

  • Promise.then() / .catch() / .finally()
  • queueMicrotask(fn)
  • MutationObserver (navigateur)
  • process.nextTick() (Node.js -- techniquement pas une microtask mais se comporte pareil, avec priorité encore plus haute)

Promise.then créé des microtasks

Quand une Promise est resolved, ses handlers .then ne s'executent pas immédiatement. Ils sont places dans la file des microtasks.

javascriptconsole.log("1");

Promise.resolve().then(() => {
  console.log("2");
});

console.log("3");
// Affiche : 1, 3, 2

Le .then est enregistre pendant l'exécution synchrone. Quand la call stack se vide (apres le console.log("3")), l'event loop draine les microtasks. Le callback affiche "2".

Ceci est vrai meme si la Promise est deja résolue. Promise.resolve() est immediate, mais le .then est toujours asynchrone.

javascriptconst p = Promise.resolve(42);
// p est DEJA resolue ici

p.then(val => console.log(val));
console.log("sync");

// Affiche : sync, 42
// Meme resolue, le .then est une microtask

queueMicrotask : la microtask explicite

ES2020 a ajoute queueMicrotask() pour créer des microtasks sans passer par une Promise :

javascriptconsole.log("A");

queueMicrotask(() => {
  console.log("B");
});

setTimeout(() => {
  console.log("C");
}, 0);

queueMicrotask(() => {
  console.log("D");
});

console.log("E");
// Affiche : A, E, B, D, C

Pourquoi B et D avant C ? Parce que B et D sont des microtasks, et C est un macrotask. Toutes les microtasks sont drainee avant le prochain macrotask.

queueMicrotask est utile quand tu veux reporter l'exécution d'un bout de code "juste apres" le code synchrone courant, mais avant tout le reste. C'est plus propre que Promise.resolve().then(fn) pour ce cas d'usage.

MutationObserver

Dans le navigateur, MutationObserver observe les changements du DOM et notifie via des microtasks :

javascriptconst observer = new MutationObserver((mutations) => {
  console.log("DOM modifie", mutations.length, "changement(s)");
});

observer.observe(document.body, { childList: true });

// Modifier le DOM
const div = document.createElement("div");
document.body.appendChild(div);

console.log("apres appendChild");
// Affiche : apres appendChild, DOM modifie 1 changement(s)

Le callback du MutationObserver est une microtask. Il s'exécuté apres le code synchrone courant, mais avant le prochain rendu. C'est parfait pour reagir aux changements DOM avant qu'ils soient peints a l'ecran.

process.nextTick (Node.js)

Dans Node.js, process.nextTick est exécuté avant les microtasks de Promise. C'est une file a part, encore plus prioritaire :

javascriptPromise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
queueMicrotask(() => console.log("microtask"));

// Affiche : nextTick, promise, microtask

Attends -- pourquoi promise avant microtask ? Parce que dans Node.js, l'ordre est :

  1. process.nextTick queue (priorité maximale)
  2. Promise microtasks
  3. queueMicrotask microtasks (meme file que les Promises en pratique, mais l'ordre depend de l'enregistrement)

En réalité, promise et microtask sont dans la meme file. L'ordre final est celui d'enregistrement : promise a ete enregistree avant microtask.

La recommendation officielle de Node.js : préféré queueMicrotask a process.nextTick dans le nouveau code. nextTick existe pour des raisons historiques.

Le drainage complet

Voici le point le plus important de cet article. Les microtasks sont drainee complètement. Ca veut dire que si une microtask en créé une autre, cette nouvelle microtask sera aussi exécutée avant le prochain macrotask.

javascriptsetTimeout(() => console.log("macro"), 0);

queueMicrotask(() => {
  console.log("micro 1");
  queueMicrotask(() => {
    console.log("micro 2");
    queueMicrotask(() => {
      console.log("micro 3");
    });
  });
});

// Affiche : micro 1, micro 2, micro 3, macro

Les trois microtasks s'executent AVANT le setTimeout. Meme si micro 2 et micro 3 n'existaient pas encore quand le drainage a commence.

Sur paltemps.fr, j'ai utilise ce mecanisme pour batir un système de validation en cascade : chaque étape de validation enregistre la suivante comme microtask, garantissant que toute la validation est terminee avant le prochain rendu.

Microtask starvation

Et voici le piège. Si tes microtasks continuent a en créer d'autres indefiniment, l'event loop ne passera jamais au macrotask suivant. Et le rendu ne se fera jamais.

javascript// NE FAIS PAS CA
function infinite() {
  queueMicrotask(infinite);
}
infinite();

// L'event loop est bloquee pour toujours
// Pas de rendu, pas de setTimeout, pas de click handler
// Le navigateur finira par tuer l'onglet

C'est exactement comme un while(true), mais plus sournois parce que le code "a l'air" asynchrone. La call stack se vide entre chaque microtask, mais l'event loop ne peut jamais avancer a l'étape suivante.

Iteration de l'event loop :

  Macrotask: [script initial]  -> Execute
  Microtasks: [infinite]       -> Execute infinite
  Microtasks: [infinite]       -> Execute infinite (cree par la precedente)
  Microtasks: [infinite]       -> Execute infinite
  ...                          -> JAMAIS de rendu, JAMAIS de macrotask

Compare avec un setTimeout recursif qui, lui, ne bloque pas :

javascript// Ceci est OK
function loop() {
  // faire du travail
  setTimeout(loop, 0);  // macrotask -> l'event loop respire
}
loop();

Résumé

  • Les microtasks (Promise.then, queueMicrotask, MutationObserver) sont dans une file séparée des macrotasks
  • Elles sont drainee entièrement entre chaque macrotask, y compris celles créées pendant le drainage
  • process.nextTick (Node.js) a priorité encore plus haute que les microtasks de Promise
  • queueMicrotask est la facon propre de créer une microtask sans Promise
  • La microtask starvation bloque l'event loop aussi efficacement qu'un while(true)

Precedent : Les Macrotasks | Suivant : Ordre d'exécution

Sources

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