Jour 19 Day 19 · mercredi 26 août 2026 Wednesday 26 August 2026 Web Fondamental

JavaScript : event loop & async JavaScript: event loop & async

Call stack, microtasks, macrotasks, promises et async/await : le « dans quel ordre ça log ? » est LA question JavaScript la plus posée en entretien de stage front comme back. Call stack, microtasks, macrotasks, promises and async/await: the "in what order does it log?" question is THE most asked JavaScript question in front-end and back-end internship interviews alike.

L’essentiel

JavaScript exécute votre code sur un seul thread : une seule instruction à la fois, une seule call stack. Et pourtant un serveur Node encaisse des milliers de requêtes simultanées et une page web reste fluide pendant un fetch. Le secret n’est pas dans le langage mais dans son environnement d’exécution : l’event loop.

Le modèle : les opérations lentes (réseau, timers, disque) sont déléguées aux APIs de l’environnement (navigateur ou Node/libuv), qui travaillent en coulisses. Quand une opération se termine, son callback est mis en file d’attente ; l’event loop le dépile et l’exécute quand la call stack est vide. On ne bloque jamais en attendant : on s’inscrit pour être rappelé.

Il n’y a pas une file mais deux, et c’est là que se jouent tous les pièges d’entretien :

MicrotasksMacrotasks (tasks)
Sources.then/.catch/.finally, await, queueMicrotasksetTimeout, setInterval, I/O, événements UI
QuandToutes, dès que la stack se videUne seule par tour de boucle
PrioritéToujours avant la macrotask suivanteAprès vidage complet des microtasks
RisqueUne chaîne infinie affame la boucleUn callback lent gèle tout

Comment ça marche

Un tour d’event loop : exécuter le code synchrone jusqu’à vider la call stack → vider entièrement la file des microtasks (y compris celles créées en cours de route) → prendre une macrotask → recommencer.

       ┌───────────────┐
       │   Call stack   │◀────────────────────┐
       └───────┬───────┘                      │
               │ stack vide ?                 │
               ▼                              │
   ┌───────────────────────┐                  │
   │ Microtasks (promises)  │── vidées EN     │
   └───────────┬───────────┘   ENTIER d'abord │
               ▼                              │
   ┌───────────────────────┐                  │
   │ Macrotasks (setTimeout,│── UNE seule,    │
   │  I/O, clics…)          │   puis re-boucle┘
   └───────────────────────┘
   Web APIs / libuv travaillent en parallèle
   et poussent les callbacks dans les files.

Le grand classique, à savoir dérouler ligne par ligne :

console.log("1");                          // synchrone : direct

setTimeout(() => console.log("2"), 0);     // macrotask, même à 0ms

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

(async () => {
  console.log("4");                        // synchrone ! avant le await
  await null;                              // suspend : la suite = microtask
  console.log("5");
})();

console.log("6");
// Ordre : 1, 4, 6, 3, 5, 2
// → tout le synchrone d'abord (1, 4, 6)
// → puis TOUTES les microtasks (3, puis 5)
// → puis la macrotask du setTimeout (2)

setTimeout(fn, 0) ne veut donc pas dire « immédiat » : ça veut dire « au plus tôt au prochain tour de boucle, après tout le synchrone et toutes les microtasks » — et si la stack est occupée 3 secondes, le callback attendra 3 secondes. Le délai est un minimum, pas une garantie.

🎤 En entretien — le « dans quel ordre ça log ? » est le grand classique absolu. La méthode qui marche : trois colonnes mentales (synchrone / microtasks / macrotasks), classer chaque ligne, puis lire colonne par colonne. Verbaliser ce classement à voix haute pendant l’exercice, c’est exactement ce que l’intervieweur veut entendre.

Concepts clés à maîtriser

  • Promise : un objet représentant une valeur future, avec trois états — pending, puis une seule fois fulfilled ou rejected (elle est alors settled, définitivement). .then retourne une nouvelle promise, d’où le chaînage plat qui a remplacé le callback hell.
  • Combinateurs : Promise.all (tout en parallèle, rejette dès le premier échec), Promise.allSettled (attend tout, donne les résultats ET les échecs), Promise.race (le premier settled gagne — pratique pour un timeout).
  • async/await : du sucre syntaxique sur les promises. Une fonction async retourne toujours une promise ; await suspend la fonction (pas le thread !) et rend la main à l’event loop — la suite de la fonction devient une microtask.
  • Gestion d’erreurs : try/catch autour d’await, ou .catch() sur la chaîne. Une rejection jamais capturée = unhandledRejection (crash possible en Node). Piège : try { maFonctionAsync() } sans await ne capture rien — la promise rejette après la sortie du try.
  • Le cas Node.js : même modèle (event loop libuv, phases timers → I/O → check), même règle d’or — ne jamais bloquer l’event loop. Un JSON.parse de 50 Mo ou une boucle de calcul gèle toutes les requêtes du serveur, pas une seule. Pour le CPU-bound : worker_threads (vrais threads avec leur propre event loop) ou découper le travail.

💡 Parallèle vs séquentiel — await a(); await b(); exécute en séquence (2× le temps). await Promise.all([a(), b()]) lance les deux en même temps. Repérer des await en série sur des opérations indépendantes est une des remarques les plus faciles à placer en revue de code… et en entretien.

En entretien

« JavaScript est single-threaded : comment gère-t-il 1000 requêtes simultanées ? » — Le thread JS n’exécute que du code court ; les opérations lentes (réseau, disque) sont déléguées à l’environnement (Web APIs, libuv) qui, lui, est asynchrone/multithreadé. Les callbacks reviennent par les files, dépilées quand la stack est vide. La concurrence vient de l’attente entrelacée, pas du parallélisme du code JS.

« Pourquoi setTimeout(fn, 0) n’exécute-t-il pas fn immédiatement ? » — Parce que fn devient une macrotask : elle attend la fin du code synchrone en cours ET le vidage complet des microtasks. 0 ms = délai minimum avant mise en file, jamais un rendez-vous.

« Microtask vs macrotask ? » — Deux files distinctes. Microtasks (callbacks de promises, await) : la file est vidée entièrement dès que la stack se libère, avant toute macrotask. Macrotasks (setTimeout, I/O, événements) : une seule par tour de boucle. Conséquence directe : un .then passe toujours avant un setTimeout(0) armé au même moment.

« Différence entre Promise.all et Promise.allSettled ? » — all rejette dès le premier échec (fail-fast) : bien quand tout est indispensable. allSettled attend toutes les promises et retourne {status, value|reason} pour chacune : bien pour des opérations indépendantes dont on veut le bilan complet.

« C’est quoi “bloquer l’event loop” en Node, et que faire pour du CPU-bound ? » — Toute tâche synchrone longue (gros parse, crypto, boucle de calcul) monopolise l’unique thread : plus aucune requête n’est servie pendant ce temps. Solutions : worker_threads pour déporter le calcul, découper en morceaux (setImmediate), ou déléguer à un service dédié.

Pièges & idées reçues

⚠️ await dans une boucle — for (const u of urls) { await fetch(u); } télécharge un par un. Si les requêtes sont indépendantes : await Promise.all(urls.map(u => fetch(u))). C’est le piège de performance async le plus courant en projet étudiant — et les intervieweurs le savent.

  • « async/await rend le code multithread » — non : c’est toujours un seul thread. await suspend la fonction, jamais le thread ; entre-temps, l’event loop exécute autre chose.
  • forEach + async ne s’attend pas : array.forEach(async x => …) n’attend rien du tout (forEach ignore les promises retournées). Utiliser for…of + await, ou Promise.all(array.map(…)).
  • Une promise démarre à sa création, pas au .then : const p = fetch(url) lance déjà la requête. Le .then ne fait que s’abonner au résultat.
  • Oublier qu’await rend la main : entre await et la ligne suivante, l’état du programme a pu changer (autre requête traitée, variable partagée modifiée). Source de bugs de concurrence subtils, même en single-thread.
  • process.nextTick (Node) passe même avant les microtasks promises — à connaître de nom, à éviter en pratique.

Pour aller plus loin

The essentials

JavaScript runs your code on a single thread: one instruction at a time, one call stack. Yet a Node server handles thousands of simultaneous requests and a web page stays smooth during a fetch. The secret is not in the language but in its runtime environment: the event loop.

The model: slow operations (network, timers, disk) are delegated to the environment’s APIs (browser or Node/libuv), which work behind the scenes. When an operation completes, its callback is queued; the event loop dequeues and runs it when the call stack is empty. You never block while waiting: you register to be called back.

There isn’t one queue but two, and that’s where every interview trap lives:

MicrotasksMacrotasks (tasks)
Sources.then/.catch/.finally, await, queueMicrotasksetTimeout, setInterval, I/O, UI events
WhenAll of them, as soon as the stack emptiesOne per loop iteration
PriorityAlways before the next macrotaskAfter the microtask queue is fully drained
RiskAn endless chain starves the loopA slow callback freezes everything

How it works

One event loop turn: run synchronous code until the call stack is empty → drain the microtask queue entirely (including microtasks created along the way) → take one macrotask → repeat.

       ┌───────────────┐
       │   Call stack   │◀────────────────────┐
       └───────┬───────┘                      │
               │ stack empty?                 │
               ▼                              │
   ┌───────────────────────┐                  │
   │ Microtasks (promises)  │── drained IN    │
   └───────────┬───────────┘   FULL first     │
               ▼                              │
   ┌───────────────────────┐                  │
   │ Macrotasks (setTimeout,│── ONE only,     │
   │  I/O, clicks…)         │   then loop back┘
   └───────────────────────┘
   Web APIs / libuv work in parallel and
   push callbacks into the queues.

The great classic, to be able to walk through line by line:

console.log("1");                          // synchronous: runs now

setTimeout(() => console.log("2"), 0);     // macrotask, even at 0ms

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

(async () => {
  console.log("4");                        // synchronous! before the await
  await null;                              // suspends: the rest = microtask
  console.log("5");
})();

console.log("6");
// Order: 1, 4, 6, 3, 5, 2
// → all synchronous code first (1, 4, 6)
// → then ALL the microtasks (3, then 5)
// → then the setTimeout macrotask (2)

So setTimeout(fn, 0) does not mean “immediately”: it means “at the earliest on the next loop turn, after all synchronous code and all microtasks” — and if the stack is busy for 3 seconds, the callback waits 3 seconds. The delay is a minimum, not a guarantee.

🎤 In an interview — the “in what order does it log?” exercise is the absolute classic. The method that works: three mental columns (synchronous / microtasks / macrotasks), sort each line into one, then read column by column. Verbalizing that sorting out loud during the exercise is exactly what the interviewer wants to hear.

Key concepts to master

  • Promise: an object representing a future value, with three states — pending, then exactly once fulfilled or rejected (it is then settled, permanently). .then returns a new promise, hence the flat chaining that replaced callback hell.
  • Combinators: Promise.all (everything in parallel, rejects on the first failure), Promise.allSettled (waits for everything, returns results AND failures), Promise.race (first settled wins — handy for a timeout).
  • async/await: syntactic sugar over promises. An async function always returns a promise; await suspends the function (not the thread!) and yields to the event loop — the rest of the function becomes a microtask.
  • Error handling: try/catch around await, or .catch() on the chain. A never-caught rejection = unhandledRejection (possible crash in Node). Trap: try { myAsyncFn() } without await catches nothing — the promise rejects after the try has exited.
  • The Node.js case: same model (libuv event loop, phases timers → I/O → check), same golden rule — never block the event loop. A 50 MB JSON.parse or a compute loop freezes all the server’s requests, not just one. For CPU-bound work: worker_threads (real threads with their own event loop) or split the work into chunks.

💡 Parallel vs sequential — await a(); await b(); runs in sequence (2× the time). await Promise.all([a(), b()]) starts both at once. Spotting serial awaits on independent operations is one of the easiest remarks to make in a code review… and in an interview.

In an interview

“JavaScript is single-threaded: how does it handle 1000 simultaneous requests?” — The JS thread only runs short code; slow operations (network, disk) are delegated to the environment (Web APIs, libuv), which is asynchronous/multithreaded. Callbacks come back through the queues, dequeued when the stack is empty. Concurrency comes from interleaved waiting, not from parallel JS code.

“Why doesn’t setTimeout(fn, 0) run fn immediately?” — Because fn becomes a macrotask: it waits for the current synchronous code to finish AND the microtask queue to fully drain. 0 ms = minimum delay before queueing, never an appointment.

“Microtask vs macrotask?” — Two distinct queues. Microtasks (promise callbacks, await): the queue is fully drained as soon as the stack frees up, before any macrotask. Macrotasks (setTimeout, I/O, events): one per loop turn. Direct consequence: a .then always runs before a setTimeout(0) armed at the same moment.

“Difference between Promise.all and Promise.allSettled?” — all rejects on the first failure (fail-fast): right when everything is required. allSettled waits for all promises and returns {status, value|reason} for each: right for independent operations where you want the full report.

“What does ‘blocking the event loop’ mean in Node, and what about CPU-bound work?” — Any long synchronous task (big parse, crypto, compute loop) monopolizes the single thread: no request is served in the meantime. Solutions: worker_threads to offload the computation, splitting into chunks (setImmediate), or delegating to a dedicated service.

Pitfalls & misconceptions

⚠️ await in a loop — for (const u of urls) { await fetch(u); } downloads one at a time. If the requests are independent: await Promise.all(urls.map(u => fetch(u))). It’s the most common async performance trap in student projects — and interviewers know it.

  • “async/await makes the code multithreaded” — no: still one thread. await suspends the function, never the thread; meanwhile, the event loop runs something else.
  • forEach + async doesn’t wait: array.forEach(async x => …) waits for nothing at all (forEach ignores returned promises). Use for…of + await, or Promise.all(array.map(…)).
  • A promise starts at creation, not at .then: const p = fetch(url) already fires the request. .then merely subscribes to the result.
  • Forgetting that await yields: between await and the next line, program state may have changed (another request handled, a shared variable modified). A source of subtle concurrency bugs, even single-threaded.
  • process.nextTick (Node) runs even before promise microtasks — know the name, avoid it in practice.

Going further

S'entraîner sur ce sujet → Practice this topic →