Skip to content

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.nextTick d'abord, puis les Promise callbacks. Bloquer la phase poll avec du CPU-bound, c'est tuer la latence de toute l'app. Mesure ton lag avec monitorEventLoopDelay, et délègue le CPU à worker_threads ou yield avec setImmediate.

🧠 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

PhaseCe qui s'y passeExemples
timersCallbacks dont l'expiration setTimeout / setInterval est atteintesetTimeout(fn, 100)
pending callbacksErreurs d'I/O système reportéesTCP ECONNREFUSED, certaines opérations qui ne tiennent pas dans poll
idle, prepareInterne libuv (non exposé)Préparation interne avant poll
pollVraiment 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'autrefs.readFile callback, socket.on('data'), http.get response
checkExécute les setImmediate enregistréssetImmediate(fn)
close callbacksCallbacks 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)

ts
// 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) et setImmediate (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 phase timers avant que le timer de 1 ms soit expiré → setImmediate passe alors en premier. Dans un callback I/O (phase poll), l'ordre devient garanti : setImmediate avant setTimeout(0) (voir pattern 1). Ne jamais s'appuyer sur l'ordre top-level en prod.

ts
// 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);
ts
// 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));
    }
  }
}
ts
// 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.

ts
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.

ts
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')); // ✅ fonctionne

3. 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.

ts
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.

ts
// 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;
}
ts
// 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.

bash
UV_THREADPOOL_SIZE=16 node server.js

6. 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.

ts
// É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, AbortController natifs. fetch exposé 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:test runner stable, --watch mode stable, performance hooks enrichis (performance.eventLoopUtilization() plus précis). Les timers Timeout/Immediate exposent [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. --watch plus rapide. WebSocket natif. 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

  1. process.nextTick en récursionprocess.nextTick(fn) se met avant les Promises et avant la phase suivante. Si fn rappelle nextTick, tu peux affamer l'I/O indéfiniment. ⚠️ Node n'émet aucun warning : l'ancien garde-fou maxTickDepth (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 via monitorEventLoopDelay ou parce que le serveur cesse de répondre.

  2. Bloquer la phase poll avec du CPU — un JSON.parse sur 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.

  3. 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", utilise setImmediate ou queueMicrotask.

  4. fs.readFileSync en production — bloque le thread JS pendant la durée de l'I/O. Acceptable au boot d'un service, jamais dans un handler HTTP.

  5. crypto.pbkdf2Sync, bcrypt.hashSync — bloquent le thread JS. Préfère les versions async qui passent par le threadpool.

  6. Saturer le threadpool — si tu hash 8 passwords bcrypt en parallèle avec 4 threads pool, les 4 derniers attendent. Augmente UV_THREADPOOL_SIZE ou throttle.

  7. unref() mal compris — un timer unref ne 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.

  8. DNS bloquant — par défaut dns.lookup (et donc http.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 (undici en a un).

  9. Confondre I/O et CPUbcrypt, JSON.parse, zlib.deflateSync, regex complexes : c'est du CPU, pas de l'I/O. Le threadpool aide pour zlib async, mais JSON.parse reste sur le thread JS.

  10. process.nextTick dans un try/catch — une exception synchrone dans un callback nextTick n'est pas attrapée par le try/catch qui l'a planifié (elle est asynchrone). Elle remonte à process.on('uncaughtException').

  11. Croire que await "yield" l'event loopawait Promise.resolve(x) est une microtask : tu cèdes au prochain microtask, pas à l'I/O en attente. Pour vraiment laisser respirer la boucle, fais await new Promise(r => setImmediate(r)).

  12. Confondre setImmediate et setImmediate du module timers/promisessetImmediate(fn) callback-style ; import { setImmediate } from 'node:timers/promises' retourne une Promise. Ne pas les confondre dans un même fichier.

🧪 Testing — node --test

ts
// 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 :

bash
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.

ts
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

OutilUtiliser quandÉviter quand
process.nextTickdifférer une émission dans un constructeur, garantir l'ordre avant la prochaine I/Oen boucle récursive, en hot path
queueMicrotaskchaîner du travail "après le script courant" sans créer de Promisequand tu veux laisser respirer l'I/O
setImmediateyielder 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 meilleursauf besoin sémantique d'un délai
worker_threadsCPU > 50 ms, parsing massif, crypto customI/O — tu vas juste déplacer le bottleneck
monitorEventLoopDelayobservabilité prod, alertes p99 lagpour 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 setImmediate enregistré → 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 exit ton 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

ts
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

ts
// 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 :

ts
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

ts
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

ts
// 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 ?

ts
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 2

Rè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

ts
// 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

  • monitorEventLoopDelay mesure le lag : combien de temps entre "ce callback devait s'exécuter" et "il s'est exécuté". Histogramme, percentiles. Granulaire.
  • eventLoopUtilization mesure 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é :

ts
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.

ts
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

Bibliothèque tech perso — Achref