Event loop libuv — phases, microtasks, blocking
TL;DR — Node n'est pas mono-thread "tout court". V8 exécute ton JS sur un seul thread, mais libuv orchestre une boucle à 6 phases qui drainent des callbacks pendant que le threadpool (4 threads par défaut) et le kernel font le sale boulot I/O. Entre chaque opération de chaque phase, deux queues de microtasks sont vidées intégralement :
process.nextTickd'abord, puis lesPromisecallbacks. Bloquer la phasepollavec du CPU-bound, c'est tuer la latence de toute l'app. Mesure ton lag avecmonitorEventLoopDelay, et délègue le CPU àworker_threadsou yield avecsetImmediate.
🧠 Mental model — ASCII + analogie
L'event loop libuv ressemble à un manège à 6 stations. Le manège tourne en continu tant qu'il reste quelque chose à faire (timers actifs, sockets ouverts, handles non fermés). À chaque station, on dépile les callbacks prêts, puis on vide entièrement les microtasks avant de bouger à la station suivante.
┌───────────────────────────┐
│ 1. timers │ setTimeout / setInterval expirés
└─────────────┬─────────────┘
│ ← microtasks (nextTick → Promises)
┌─────────────▼─────────────┐
│ 2. pending callbacks │ certains I/O errors (TCP ECONNREFUSED…)
└─────────────┬─────────────┘
│ ← microtasks
┌─────────────▼─────────────┐
│ 3. idle, prepare │ interne libuv (jamais utilisé direct)
└─────────────┬─────────────┘
│ ← microtasks
┌─────────────▼─────────────┐
│ 4. poll │ ← le cœur : I/O ready, fs, net, dns…
│ (peut bloquer ici │ bloque jusqu'au prochain timer
│ si rien à faire) │ ou indéfiniment si rien d'autre
└─────────────┬─────────────┘
│ ← microtasks
┌─────────────▼─────────────┐
│ 5. check │ setImmediate
└─────────────┬─────────────┘
│ ← microtasks
┌─────────────▼─────────────┐
│ 6. close callbacks │ socket.on('close'), etc.
└─────────────┬─────────────┘
│ ← microtasks
└──► retour à (1)Analogie : imagine un serveur de restaurant (le thread JS) qui tourne entre 6 tables (les phases). À chaque table, il prend les commandes prêtes. Mais avant de passer à la table suivante, il vide d'un coup la pile de petits papiers urgents que le chef lui a glissés (microtasks). process.nextTick est un papier rouge urgent, les Promise sont jaunes. Le chef vide tous les rouges avant les jaunes.
Le threadpool (par défaut 4 threads, configurable via UV_THREADPOOL_SIZE) est la cuisine : fs.*, crypto.pbkdf2, dns.lookup, zlib y vont. Le réseau (net, http, https, TCP) ne passe pas par le threadpool : il utilise directement les primitives du kernel (epoll sur Linux, kqueue sur macOS, IOCP sur Windows).
Détail des phases
| Phase | Ce qui s'y passe | Exemples |
|---|---|---|
| timers | Callbacks dont l'expiration setTimeout / setInterval est atteinte | setTimeout(fn, 100) |
| pending callbacks | Erreurs d'I/O système reportées | TCP ECONNREFUSED, certaines opérations qui ne tiennent pas dans poll |
| idle, prepare | Interne libuv (non exposé) | Préparation interne avant poll |
| poll | Vraiment où on passe le plus de temps : récupère les nouvelles I/O event du kernel, exécute leurs callbacks ; peut bloquer s'il n'y a rien d'autre | fs.readFile callback, socket.on('data'), http.get response |
| check | Exécute les setImmediate enregistrés | setImmediate(fn) |
| close callbacks | Callbacks de fermeture (socket.on('close'), etc.) | Cleanup de handles |
Entre chaque opération individuelle d'une phase, Node vide deux microtask queues : d'abord process.nextTick, ensuite la queue Promise (donc queueMicrotask et .then). C'est ce vidage agressif qui fait que process.nextTick peut bloquer la boucle si tu y boucles récursivement — il ne laisse jamais la main à la phase suivante.
🛠️ Code minimal (ts/js)
// Démonstration de l'ordre exact des callbacks
import { setImmediate as setImmediateAsync } from 'node:timers/promises';
console.log('1. sync start');
setTimeout(() => console.log('5. setTimeout(0)'), 0);
setImmediate(() => console.log('6. setImmediate'));
process.nextTick(() => console.log('3. nextTick'));
queueMicrotask(() => console.log('4a. queueMicrotask'));
Promise.resolve().then(() => console.log('4b. promise.then'));
console.log('2. sync end');
// Sortie observable :
// 1. sync start
// 2. sync end
// 3. nextTick ← microtasks "nextTick" en premier
// 4a. queueMicrotask ← microtasks "promise queue"
// 4b. promise.then
// 5. setTimeout(0) ← phase timers ⚠️ voir note ci-dessous
// 6. setImmediate ← phase check⚠️ Au top-level, l'ordre entre
setTimeout(0)(5) etsetImmediate(6) n'est PAS garanti. Les microtasks (1→4b) sont déterministes. Mais selon le temps de bootstrap du process, la boucle peut entrer en phasetimersavant que le timer de 1 ms soit expiré →setImmediatepasse alors en premier. Dans un callback I/O (phasepoll), l'ordre devient garanti :setImmediateavantsetTimeout(0)(voir pattern 1). Ne jamais s'appuyer sur l'ordre top-level en prod.
// Mesurer le lag de l'event loop
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
// Simule un blocage CPU-bound de 200 ms
const start = performance.now();
while (performance.now() - start < 200) {
// hot loop synchrone — bloque la phase courante
}
setTimeout(() => {
histogram.disable();
console.log({
min_ms: histogram.min / 1e6,
mean_ms: histogram.mean / 1e6,
max_ms: histogram.max / 1e6,
p99_ms: histogram.percentile(99) / 1e6,
});
histogram.reset();
}, 500);// Yielder proprement pendant un traitement long
async function processHugeArray<T>(items: T[], fn: (item: T) => void) {
const BATCH = 1_000;
for (let i = 0; i < items.length; i++) {
fn(items[i]);
if (i % BATCH === 0) {
// setImmediate libère la boucle : on laisse passer les I/O en attente
await new Promise<void>((r) => setImmediate(r));
}
}
}// Observer en continu et exposer comme métrique Prometheus
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
import { createServer } from 'node:http';
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
let lastELU = performance.eventLoopUtilization();
const server = createServer((_req, res) => {
// Snapshot courant, puis delta entre maintenant et le dernier scrape.
// ORDRE DES ARGS : eventLoopUtilization(récent, ancien) → fenêtre [ancien, récent].
const now = performance.eventLoopUtilization();
const elu = performance.eventLoopUtilization(now, lastELU);
lastELU = now; // on réutilise le MÊME snapshot, sinon le delta est faussé
res.setHeader('content-type', 'text/plain');
res.end(
[
`nodejs_eventloop_lag_p50_ms ${histogram.percentile(50) / 1e6}`,
`nodejs_eventloop_lag_p99_ms ${histogram.percentile(99) / 1e6}`,
`nodejs_eventloop_lag_max_ms ${histogram.max / 1e6}`,
`nodejs_eventloop_utilization ${elu.utilization}`,
].join('\n'),
);
});
server.listen(9090);🎯 Patterns courants — 6
1. setImmediate vs setTimeout(0) — règle utilisable
Dans un callback I/O (déjà dans la phase poll), setImmediate s'exécute toujours avant un setTimeout(0) planifié au même moment. C'est garanti par la séquence des phases : on passe de poll à check directement, alors que les timers sont à la prochaine itération.
import fs from 'node:fs';
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// → "immediate" toujours en premier ici
});Hors I/O (top-level), l'ordre est non déterministe car les deux dépendent de quand la boucle démarre vraiment.
Règle pratique : pour "yielder à l'event loop" depuis un callback I/O, utilise setImmediate. Pour "exécuter dans X millisecondes", utilise setTimeout.
2. process.nextTick pour différer une émission synchrone
Quand tu construis une API qui émet un événement, tu veux que les .on(...) enregistrés après le constructeur soient bien attachés avant l'émission. process.nextTick te le garantit.
import { EventEmitter } from 'node:events';
class MyResource extends EventEmitter {
constructor() {
super();
// PAS : this.emit('ready') — trop tôt, personne n'écoute encore
process.nextTick(() => this.emit('ready'));
}
}
const r = new MyResource();
r.on('ready', () => console.log('ready received')); // ✅ fonctionne3. queueMicrotask plutôt que Promise.resolve().then
Sémantiquement équivalent, mais queueMicrotask est plus explicite, n'alloue pas une Promise, ne capture pas les rejets en chaîne, et a un meilleur trace stack. Standard depuis Node 11.
queueMicrotask(() => doSomethingAfterCurrentScript());4. Découper le CPU-bound — yielding et workers
Pour un job synchrone < 50 ms, setImmediate suffit. Au-delà, worker_threads est la bonne réponse. Ne tente pas de "découper finement" un parsing JSON de 200 MB : JSON.parse reste atomique.
// CPU-bound léger : yielding
async function sumLargeArray(arr: number[]) {
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum += arr[i];
if ((i & 0xffff) === 0) await new Promise((r) => setImmediate(r));
}
return sum;
}// CPU-bound lourd : worker
import { Worker } from 'node:worker_threads';
const worker = new Worker(new URL('./hash-worker.ts', import.meta.url));
worker.postMessage({ data: bigBuffer });
worker.on('message', (hash) => console.log(hash));5. Détecter le starving du threadpool
Si tu fais beaucoup de crypto.pbkdf2, fs.readFile ou zlib, tu peux saturer les 4 threads par défaut. Symptôme : la latence des callbacks fs explose même quand l'event loop semble libre. Augmente UV_THREADPOOL_SIZE (max 1024) avant de démarrer Node.
UV_THREADPOOL_SIZE=16 node server.js6. Garder l'event loop "honnête" sous charge
Expose une métrique de lag (monitorEventLoopDelay.percentile(99)) dans Prometheus. Au-delà de 50 ms p99, c'est rouge. Au-delà de 200 ms, tes healthchecks vont commencer à timeout en cascade.
// Échantillonnage continu + log si dépassement de seuil
import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
const p99 = h.percentile(99) / 1e6; // ns → ms
if (p99 > 100) {
console.warn('[alerte] event loop lag p99 =', p99.toFixed(1), 'ms');
}
h.reset();
}, 10_000);🔄 Versions — Node 18 / 20 / 22 / 24
- Node 18 (avril 2022, LTS jusqu'avril 2025) — base stable.
monitorEventLoopDelay,queueMicrotask,AbortControllernatifs.fetchexposé global (undici sous le capot, qui a son propre dispatcher mais consomme l'event loop principal). - Node 20 (avril 2023, LTS jusqu'avril 2026) —
node:testrunner stable,--watchmode stable, performance hooks enrichis (performance.eventLoopUtilization()plus précis). Les timersTimeout/Immediateexposent[Symbol.dispose](using/await using). - Node 22 (avril 2024, LTS jusqu'avril 2027) —
require(esm)permet d'require()un module ESM purement synchrone (sans top-level await). Améliore drastiquement les scénarios où une lib publie ESM-only mais est consommée par du CJS.--watchplus rapide.WebSocketnatif. C'est la version LTS recommandée en 2026. - Node 24 (avril 2025) —
require(esm)activé par défaut (plus besoin du flag). Web Streams plus performants. URL parsing via Ada 2.x. V8 13.x avec améliorations sur les microtasks (queue moins de réallocations). Pour l'event loop en soi, peu de changement sémantique — c'est la même libuv.
⚠️ Le comportement de l'event loop a subtilement changé entre Node 10 et 11 : avant 11, les microtasks ne se vidaient pas systématiquement entre chaque timer. Depuis 11 c'est le comportement actuel. Si tu vois un vieux article qui décrit autre chose, il parle d'un Node antérieur.
⚠️ Pitfalls — 12
process.nextTicken récursion —process.nextTick(fn)se met avant les Promises et avant la phase suivante. SifnrappellenextTick, tu peux affamer l'I/O indéfiniment. ⚠️ Node n'émet aucun warning : l'ancien garde-foumaxTickDepth(warning "Recursive process.nextTick detected" au-delà de 1000) a été retiré dès Node 0.11.3 et n'existe plus dans les versions modernes. La famine est donc silencieuse — tu ne la verras que viamonitorEventLoopDelayou parce que le serveur cesse de répondre.Bloquer la phase
pollavec du CPU — unJSON.parsesur 100 MB, une regex pathologique (catastrophic backtracking), un loop synchrone de 500 ms : tout ça bloque toutes les autres connexions de ton serveur HTTP simultanément.Croire que
setTimeout(fn, 0)est immédiat — minimum 1 ms garanti par libuv (et en pratique souvent 4 ms sur certains OS). Pour "yielder", utilisesetImmediateouqueueMicrotask.fs.readFileSyncen production — bloque le thread JS pendant la durée de l'I/O. Acceptable au boot d'un service, jamais dans un handler HTTP.crypto.pbkdf2Sync,bcrypt.hashSync— bloquent le thread JS. Préfère les versions async qui passent par le threadpool.Saturer le threadpool — si tu hash 8 passwords bcrypt en parallèle avec 4 threads pool, les 4 derniers attendent. Augmente
UV_THREADPOOL_SIZEou throttle.unref()mal compris — un timerunrefne maintient pas la boucle vivante, mais s'exécutera quand même si elle reste vivante pour autre chose. Utile pour des keepalives qui ne doivent pas empêcher l'exit.DNS bloquant — par défaut
dns.lookup(et donchttp.get('http://...')) passe par le threadpool. Sous forte charge, ça bloque les autres opérations fs. Solutions :dns.resolve*(qui n'utilise pas le pool) ou un cache DNS (undicien a un).Confondre I/O et CPU —
bcrypt,JSON.parse,zlib.deflateSync, regex complexes : c'est du CPU, pas de l'I/O. Le threadpool aide pourzlibasync, maisJSON.parsereste sur le thread JS.process.nextTickdans un try/catch — une exception synchrone dans un callbacknextTickn'est pas attrapée par le try/catch qui l'a planifié (elle est asynchrone). Elle remonte àprocess.on('uncaughtException').Croire que
await"yield" l'event loop —await Promise.resolve(x)est une microtask : tu cèdes au prochain microtask, pas à l'I/O en attente. Pour vraiment laisser respirer la boucle, faisawait new Promise(r => setImmediate(r)).Confondre
setImmediateetsetImmediatedu moduletimers/promises—setImmediate(fn)callback-style ;import { setImmediate } from 'node:timers/promises'retourne une Promise. Ne pas les confondre dans un même fichier.
🧪 Testing — node --test
// event-loop.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { monitorEventLoopDelay } from 'node:perf_hooks';
test('process.nextTick s’exécute avant les Promises', async () => {
const order: string[] = [];
Promise.resolve().then(() => order.push('promise'));
process.nextTick(() => order.push('nextTick'));
await new Promise((r) => setImmediate(r));
assert.deepEqual(order, ['nextTick', 'promise']);
});
test('setImmediate s’exécute après I/O', async () => {
const order: string[] = [];
await new Promise<void>((resolve) => {
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => order.push('timeout'), 0);
setImmediate(() => {
order.push('immediate');
resolve();
});
});
});
// immediate est en premier dans le callback I/O
assert.equal(order[0], 'immediate');
});
test('blocking the loop est détectable', async () => {
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
const start = Date.now();
while (Date.now() - start < 100) {
/* CPU spin 100 ms */
}
await new Promise((r) => setTimeout(r, 50));
h.disable();
// p99 doit avoir explosé
assert.ok(h.percentile(99) / 1e6 > 50, 'lag p99 > 50 ms attendu');
});Lance avec :
node --test --experimental-strip-types event-loop.test.ts🎬 Cas d'usage concrets
Scénario 1 — API banque latency-sensitive (paiements instantanés)
Une banque expose une API REST pour le scoring antifraude en temps réel sur des virements SEPA Instant (deadline réglementaire 10 secondes). Le P99 ne doit jamais dépasser 200 ms. Le service reçoit ~3000 req/s en pic, chaque requête déclenche un redis.get (rules cache), un appel HTTP vers le moteur ML interne, et un pg write pour l'audit.
L'event-loop est le facteur limitant : un seul callback synchrone trop lourd (parsing JSON 2 MB, regex catastrophique, JSON.stringify d'un objet circulaire) bloque toutes les requêtes en file. L'équipe instrumente perf_hooks.monitorEventLoopDelay et publie le P99 du lag dans Datadog. Toute boucle sur un tableau > 1000 entrées passe par setImmediate pour yielder. Les hash bcrypt synchrones (legacy) ont été migrés vers crypto.scrypt async ou un worker thread dédié. Un microtask runaway sur Promise.then (chain de 50 niveaux dans un mapper) a été simplifié pour éviter la famine de la phase poll.
Gain mesuré : P99 ramené de 480 ms à 140 ms en supprimant deux microtasks récursifs et en déléguant le hash KYC à un pool.
Scénario 2 — E-commerce recherche non-bloquante (Black Friday)
Plateforme e-commerce (catalogue 2 M produits) avec recherche full-text Meilisearch et reranking ML local. Le endpoint /search doit rester sous 80 ms même quand un autre endpoint déclenche un calcul d'agrégation analytics (CPU 200 ms).
Sans précaution, l'agrégation bloque l'event-loop et tue le P99 search. Solution : tout calcul > 10 ms est découpé via setImmediate (yield après chaque batch de 500 items) ou délégué à un Worker. Les writes Postgres bulk sont batchés par queueMicrotask pour grouper sans bloquer. L'équipe a aussi banni JSON.parse sur les payloads > 256 KB dans le hot path — remplacé par streaming JSONStream.
Pendant le Black Friday 2025, le throughput a tenu 12k req/s sans dégradation parce qu'aucun handler ne tient l'event-loop plus de 5 ms.
Scénario 3 — Cabinet juridique agrégateur Legifrance + Doctrine async
Outil interne pour avocats : interroge en parallèle Legifrance, Doctrine, Dalloz, EUR-Lex, et un index ES interne. Chaque requête fan-out 5-8 sources, agrège, dédoublonne, renvoie en < 2 s.
L'event-loop coordonne Promise.allSettled sur les 8 fetch concurrents. Le piège : un JSON.parse synchrone de la réponse Doctrine (3 MB de jurisprudence) bloquait 90 ms. Migration vers Response.json() streaming + worker thread pour le dédoublonnage (Levenshtein sur titres). Les timeouts par source utilisent AbortController armé avec setTimeout, garantissant qu'une source lente ne retarde pas la phase timers globale.
🛠️ Exemple end-to-end
Endpoint scoring antifraude qui cumule les patterns event-loop critiques : monitoring du lag, yield CPU, microtask discipline, timer-based timeout.
import { createServer } from "node:http";
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
import { setImmediate as setImmediateP } from "node:timers/promises";
const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();
setInterval(() => {
const p99Ms = histogram.percentile(99) / 1e6;
if (p99Ms > 50) console.warn(`[loop-lag] P99=${p99Ms.toFixed(1)}ms`);
histogram.reset();
}, 5000);
type Tx = { id: string; amount: number; iban: string; features: number[] };
async function scoreRules(tx: Tx): Promise<number> {
// I/O bound: Redis lookup (simulated)
await new Promise((r) => setTimeout(r, 2));
return tx.amount > 10_000 ? 0.4 : 0.05;
}
async function scoreModel(tx: Tx, signal: AbortSignal): Promise<number> {
// HTTP call to internal ML service with strict timeout
const res = await fetch("http://ml.internal/score", {
method: "POST",
body: JSON.stringify({ f: tx.features }),
signal,
});
if (!res.ok) throw new Error(`ml ${res.status}`);
const { score } = (await res.json()) as { score: number };
return score;
}
// CPU-bound aggregator: yield every 256 features to avoid blocking
async function normalizeFeatures(raw: number[]): Promise<number[]> {
const out = new Array(raw.length);
for (let i = 0; i < raw.length; i++) {
out[i] = Math.log1p(Math.abs(raw[i]));
if ((i & 255) === 0) await setImmediateP(); // yield to poll phase
}
return out;
}
const server = createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/score") {
res.statusCode = 404;
return res.end();
}
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c);
const tx = JSON.parse(Buffer.concat(chunks).toString()) as Tx;
const t0 = performance.now();
const ac = new AbortController();
const timeout = setTimeout(() => ac.abort(), 150);
try {
tx.features = await normalizeFeatures(tx.features);
const [rules, model] = await Promise.all([
scoreRules(tx),
scoreModel(tx, ac.signal),
]);
const score = 0.3 * rules + 0.7 * model;
res.setHeader("content-type", "application/json");
res.setHeader("x-elapsed-ms", (performance.now() - t0).toFixed(1));
res.end(JSON.stringify({ id: tx.id, score, decision: score > 0.6 ? "block" : "allow" }));
} catch (err) {
res.statusCode = ac.signal.aborted ? 504 : 500;
res.end(JSON.stringify({ error: (err as Error).message }));
} finally {
clearTimeout(timeout);
}
});
server.listen(3000);Points clés : monitorEventLoopDelay détecte les régressions, setImmediate yield la phase poll pendant la normalisation CPU, AbortController + setTimeout borne le temps total même si l'ML est lent, Promise.all parallélise rules + model sur le même tick.
🔁 Quand utiliser / éviter
| Outil | Utiliser quand | Éviter quand |
|---|---|---|
process.nextTick | différer une émission dans un constructeur, garantir l'ordre avant la prochaine I/O | en boucle récursive, en hot path |
queueMicrotask | chaîner du travail "après le script courant" sans créer de Promise | quand tu veux laisser respirer l'I/O |
setImmediate | yielder dans une boucle CPU-bound courte, exécuter "après la phase poll" | comme remplaçant générique de setTimeout(fn, ms) |
setTimeout(fn, 0) | jamais en réalité — un setImmediate est presque toujours meilleur | sauf besoin sémantique d'un délai |
worker_threads | CPU > 50 ms, parsing massif, crypto custom | I/O — tu vas juste déplacer le bottleneck |
monitorEventLoopDelay | observabilité prod, alertes p99 lag | pour du debug ponctuel (préfère eventLoopUtilization) |
🧬 Aller plus loin — les détails que peu de devs connaissent
Comment libuv décide-t-il de bloquer en phase poll ?
Quand la boucle arrive en phase poll et qu'il n'y a plus de callbacks I/O à drainer, libuv calcule combien de temps elle peut dormir :
- S'il y a un
setImmediateenregistré → ne pas dormir, passer àcheck. - Sinon, regarder le prochain timer : dormir jusqu'à son expiration (avec
epoll_wait(timeout)). - Sinon (aucun handle actif), sortir de la boucle : c'est ce qui fait
exitton process si plus rien n'attend.
Cette logique explique pourquoi setImmediate ne bloque jamais l'event loop : libuv le "voit" et ne dort pas. Un setTimeout(fn, 0) met une expiration à 1 ms et libuv dort 1 ms, ce qui est en pratique invisible mais sémantiquement différent.
eventLoopUtilization() — la métrique préférée des SRE
import { performance } from 'node:perf_hooks';
const before = performance.eventLoopUtilization();
// ... attendre quelques secondes ...
setTimeout(() => {
const after = performance.eventLoopUtilization();
const utilization = performance.eventLoopUtilization(after, before);
console.log('event loop utilization', utilization);
// { idle: 4892.31, active: 107.69, utilization: 0.0215 }
}, 5000);utilization est un ratio entre 0 et 1 : % du temps où l'event loop n'était pas idle. À 0.7+ tu commences à avoir des soucis de latence. Métriques à exposer obligatoirement en prod, avec monitorEventLoopDelay.percentile(99).
Ordre subtil entre setImmediate et setTimeout(0) au top-level
// Au top level (pas dans un callback I/O), l'ordre est NON déterministe
setTimeout(() => console.log('A'), 0);
setImmediate(() => console.log('B'));
// Tu peux voir "A B" ou "B A" selon le timing exact de démarrage.À l'intérieur d'un callback I/O, l'ordre est garanti :
import { readFile } from 'node:fs';
readFile(__filename, () => {
setTimeout(() => console.log('A'), 0);
setImmediate(() => console.log('B'));
// Garanti : "B" puis "A"
});process.nextTick est implémenté hors libuv
Petit détail important : process.nextTick n'est pas une phase libuv. C'est une queue Node-native, vidée par le runtime entre chaque opération JS. C'est pour ça qu'elle a priorité sur tout. La queue des Promises (microtasks JS), elle, est gérée par V8.
AbortController et timers
import { setTimeout as wait } from 'node:timers/promises';
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 100);
try {
await wait(5000, undefined, { signal: ctrl.signal });
} catch (err) {
if ((err as Error).name === 'AbortError') {
console.log('annulé');
}
}Couplé avec fetch, pipeline(), setTimeout promisifié, etc. — c'est le pattern d'annulation moderne en Node.
Affamer la boucle avec process.nextTick récursif — démonstration
// Ce code bloque tout le serveur HTTP pendant ~secondes
import http from 'node:http';
const server = http.createServer((_req, res) => res.end('ok'));
server.listen(3000);
function tick() {
// Chaque nextTick re-planifie le suivant : la queue ne se vide jamais
// → on ne passe jamais à la phase poll, donc aucune requête HTTP n'est servie
process.nextTick(tick);
}
setTimeout(tick, 1000);Solution : utilise setImmediate(tick) à la place, qui laisse passer une itération complète de la boucle entre deux callbacks.
Promise chain vs process.nextTick — qui gagne ?
process.nextTick(() => console.log('nextTick 1'));
Promise.resolve().then(() => {
console.log('promise 1');
process.nextTick(() => console.log('nextTick 2'));
Promise.resolve().then(() => console.log('promise 2'));
});
process.nextTick(() => console.log('nextTick 3'));
// Sortie :
// nextTick 1
// nextTick 3 ← toute la queue nextTick d'abord
// promise 1 ← puis Promise queue
// nextTick 2 ← nextTick 2 est queued APRÈS promise 1 → mais
// Node revide la queue nextTick avant de continuer les Promises
// promise 2Règle : la queue nextTick est revidée avant chaque microtask Promise. Donc même un nextTick schedulé depuis un .then saute devant la suite de la chaîne Promise.
setInterval vs récursion setTimeout
// setInterval garantit la fréquence mais peut empiler si le callback est lent
setInterval(async () => {
await slowQuery(); // si > 1 s, le suivant démarre quand même
}, 1000);
// setTimeout récursif garantit l'écart minimum entre deux exécutions
async function tick() {
await slowQuery();
setTimeout(tick, 1000);
}
setTimeout(tick, 1000);En production, préfère quasi-toujours la version récursive pour les jobs longs : pas de risque d'empilement.
eventLoopUtilization vs monitorEventLoopDelay — différence
monitorEventLoopDelaymesure le lag : combien de temps entre "ce callback devait s'exécuter" et "il s'est exécuté". Histogramme, percentiles. Granulaire.eventLoopUtilizationmesure le busy ratio : sur une fenêtre, quel % de temps l'event loop n'était pas idle. Indicateur agrégé, utile pour autoscaling.
Tu veux les deux en prod : alerter sur p99 lag > 100 ms ET utilization > 0.7.
Pourquoi le DNS bloque-t-il le threadpool ?
dns.lookup(host) utilise getaddrinfo(), une fonction libc synchrone qui peut prendre des centaines de millisecondes (résolution réseau). libuv l'appelle depuis le threadpool — d'où la consommation d'un thread pour chaque résolution. Si tu fais 10 lookups parallèles et que tu n'as que 4 threads pool, les 6 derniers attendent.
Alternative : dns.resolve4/6/Cname/... utilise un client DNS asynchrone natif (c-ares), qui ne consomme pas de thread pool. Mais le résolveur c-ares n'utilise pas /etc/hosts ni nsswitch.conf — il interroge directement les serveurs DNS configurés. Pour la plupart des cas (résolution Internet pure), c'est préférable.
Pour fetch() / undici, tu peux configurer un dispatcher avec un cache DNS pour éviter le coût répété :
import { Agent, setGlobalDispatcher } from 'undici';
setGlobalDispatcher(new Agent({ connections: 100, pipelining: 10 }));worker_threads vs event loop principal
Chaque worker a son propre event loop libuv. Donc bloquer un worker ne bloque pas le main. Mais attention : le threadpool libuv (4 threads) est partagé entre le main et tous les workers ! Si tes workers font du fs.readFile, ils piochent dans le même pool que le main, et peuvent l'affamer.
Pour isoler totalement, lance chaque worker dans un sous-process (child_process.fork) plutôt qu'un thread.
🏋️ Exercices
Progression : prédire → instrumenter → production-grade → casser-puis-réparer. Fais-les dans l'ordre. Toutes les solutions sont des esquisses : l'intérêt est de buter sur le détail.
Exercice 1 — Prédire l'ordre (warm-up, mais piège)
Objectif : prédire la sortie EXACTE du snippet, puis la vérifier avec node.
console.log('A');
setImmediate(() => console.log('B'));
Promise.resolve().then(() => {
console.log('C');
process.nextTick(() => console.log('D'));
});
process.nextTick(() => console.log('E'));
setTimeout(() => console.log('F'), 0);
console.log('G');Indice/Solution : A, G (sync) → E (nextTick avant Promises) → C (Promise) → D (nextTick re-vidé AVANT la suite des microtasks) → puis F/B dans un ordre non garanti au top-level (timers vs check). Le piège est D qui saute devant F et B parce que la queue nextTick est re-drainée entre chaque microtask Promise.
Exercice 2 — Mesurer le coût réel d'un yield
Objectif : quantifier le surcoût de setImmediate vs queueMicrotask vs aucun yield sur une boucle de 10M itérations, et expliquer pourquoi l'un n'aide pas l'I/O.
Indice/Solution : monitorEventLoopDelay autour d'un http.createServer qui répond ok, plus une boucle CPU de fond. Variante A (pas de yield) : le serveur ne répond plus pendant la boucle. Variante B (queueMicrotask tous les 0xffff) : le serveur ne répond TOUJOURS pas (microtask ≠ I/O — tu restes dans le même tick). Variante C (setImmediate) : le serveur répond entre les batches. Conclusion à formuler : seul setImmediate (ou un timer) cède réellement à la phase poll.
Exercice 3 — Healthcheck event-loop-aware (production-grade)
Objectif : écrire un endpoint /healthz qui renvoie 503 quand le P99 du lag dépasse 100 ms OU que l'eventLoopUtilization dépasse 0.9 sur la dernière fenêtre, et 200 sinon — pour que le load balancer retire l'instance d'elle-même AVANT de cascader des timeouts.
Indice/Solution : monitorEventLoopDelay enable global + eventLoopUtilization snapshot/delta par scrape (réutilise le pattern Prometheus corrigé plus haut). reset() l'histogramme à chaque scrape sinon le P99 est cumulatif depuis le boot. Piège classique : si le handler /healthz fait lui-même un await fetch, il sera lui aussi en file derrière le blocage → garde-le 100 % synchrone (lecture mémoire seulement).
Exercice 4 — Déléguer un CPU lourd à un worker avec backpressure
Objectif : un endpoint /hash qui dérive un scrypt coûteux (N=2^17) sans bloquer la boucle ET sans saturer la mémoire sous 5000 req/s — donc avec une file bornée vers un pool de worker_threads.
Indice/Solution : pool de os.availableParallelism() workers, une queue applicative bornée (ex. 1000). Au-delà : renvoie 429 immédiatement plutôt que d'empiler. Mesure que le P99 du lag main reste plat (le scrypt tourne dans le worker, pas sur le main). Piège : crypto.scrypt async passe déjà par le threadpool libuv — le worker n'est utile que si tu veux isoler le pool ou faire du JS CPU pur (parsing, sérialisation). Sache justifier worker vs async natif.
Exercice 5 — Casser puis réparer : la famine nextTick
Objectif : reproduire une famine où un serveur HTTP cesse de répondre, l'observer dans monitorEventLoopDelay, puis la corriger sans changer la sémantique métier.
Indice/Solution : pars du snippet process.nextTick(tick) récursif. Lance autocannon http://localhost:3000 → 0 req servie. Le lag P99 explose (ou n'est même plus mesuré car le timer ne s'exécute jamais). Fix : remplace par setImmediate(tick) → la phase poll reprend la main entre chaque itération et les requêtes passent. Bonus : montre que la famine est silencieuse sur Node moderne — aucun warning n'est émis (le garde-fou maxTickDepth a disparu depuis Node 0.11.3) ; le seul signal est le lag mesuré par monitorEventLoopDelay (ou son absence totale de tick si le timer ne s'exécute jamais).
Exercice 6 — Casser puis réparer : starvation du threadpool
Objectif : démontrer que dns.lookup sous charge affame fs.readFile, puis réparer sans augmenter UV_THREADPOOL_SIZE.
Indice/Solution : boucle de 200 dns.lookup('example-XXX.com') en parallèle + un fs.readFile chronométré. Le readFile voit sa latence exploser (il attend un slot dans les 4 threads). Deux fixes possibles sans toucher au pool : (1) basculer vers dns.resolve4 (c-ares, hors pool), (2) mettre un cache DNS (undici Agent). Mesure la latence du readFile avant/après. Sache expliquer pourquoi le réseau TCP, lui, ne souffre pas (epoll/kqueue, pas le pool).
🎤 En entretien
Q : « Node est mono-thread. Comment sert-il alors 10 000 connexions simultanées sans bloquer ? » R : Le JS s'exécute sur un seul thread V8, mais l'I/O est non-bloquante : libuv enregistre les sockets dans epoll/kqueue/IOCP et le kernel notifie quand des données sont prêtes. Le thread JS ne fait que drainer des callbacks en phase poll. La concurrence vient de l'attente déléguée au kernel, pas du parallélisme JS. Le seul vrai danger est un callback CPU-bound qui monopolise le thread.
Q : « Différence entre process.nextTick et queueMicrotask / Promise.then ? » R : Les deux sont des microtasks vidées entre chaque opération, mais la queue nextTick est gérée par Node (pas par V8) et a priorité absolue : elle est entièrement vidée — et RE-vidée entre chaque microtask Promise. Conséquence : nextTick récursif affame la boucle, alors qu'une chaîne Promise laisse au moins respirer les autres microtasks. En pratique : queueMicrotask pour du travail post-script standard, nextTick réservé aux APIs internes qui doivent garantir un ordre avant la prochaine I/O.
Q : « Dans un callback I/O, setImmediate ou setTimeout(0) s'exécute en premier ? Et au top-level ? » R : Dans un callback I/O (phase poll), setImmediate est garanti avant setTimeout(0) car on passe directement de poll à check, tandis que les timers attendent la prochaine itération. Au top-level, l'ordre est non déterministe : il dépend de si le timer de 1 ms (minimum libuv) est expiré quand la boucle entre en phase timers. Règle : ne jamais s'appuyer sur l'ordre top-level.
Q : « Comment détectes-tu en production qu'un endpoint bloque l'event loop, et que fais-tu ? » R : Deux métriques complémentaires exposées en continu : monitorEventLoopDelay.percentile(99) (lag granulaire, alerte > 100 ms) et performance.eventLoopUtilization() (busy ratio, alerte > 0.7, utile pour l'autoscaling). Pour le remède : profiler avec --prof ou clinic flame pour isoler le callback fautif, puis selon la nature — yield via setImmediate pour du CPU léger, worker_threads/child_process pour du CPU lourd, streaming au lieu de JSON.parse sur gros payloads, dns.resolve/cache pour le DNS. Bonus senior : un /healthz qui renvoie 503 sous lag élevé pour que le LB retire l'instance avant la cascade de timeouts.
🔗 Liens
- Doc officielle : https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
- libuv design overview : http://docs.libuv.org/en/v1.x/design.html
perf_hooks: https://nodejs.org/api/perf_hooks.html- Bert Belder (libuv author) — "Everything You Need to Know About Node.js Event Loop" : Node.js Interactive talk
- Node 22
require(esm)impact sur les modules : https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require eventLoopUtilization: https://nodejs.org/api/perf_hooks.html#performanceeventlooputilizationutilization1-utilization2AbortControllerdans Node : https://nodejs.org/api/globals.html#class-abortcontroller- Article Daniel Khan — "Hidden gotchas of Node event loop"
- Talks Matteo Collina sur perf Node (NodeConf, JSConf)
- Article Trevor Norris — "Understanding Node async hooks" (concepts adjacents)
- Source libuv (loop.c, core.c) : https://github.com/libuv/libuv