Skip to content

Worker threads, cluster et child_process — paralléliser Node.js sans se brûler

TL;DR — Node.js est mono-thread sur la boucle d'événements, mais offre trois mécanismes pour faire du parallélisme réel : worker_threads pour le CPU-bound dans le même process (mémoire partageable via SharedArrayBuffer), child_process pour spawn des processus séparés (isolation totale, IPC via stdio ou canal dédié), et cluster pour forker plusieurs workers HTTP partageant un port. En production moderne, on évite cluster au profit d'un orchestrateur externe (PM2, systemd, Kubernetes) qui sait redémarrer, équilibrer et observer. Les workers utilisent MessagePort pour communiquer ; on choisit entre transfer (zero-copy, l'émetteur perd l'objet) et structured clone (copie profonde, coûteuse). Pour un pool de workers prêt à l'emploi, piscina est l'état de l'art. child_process.spawn est l'API la plus bas niveau et la plus prévisible ; exec charge tout en mémoire et expose des risques d'injection shell ; execFile est l'équivalent sans shell ; fork est un raccourci pour lancer un autre script Node avec un canal IPC. La discipline clé : isoler le code chaud, mesurer avant d'optimiser, et toujours prévoir un mécanisme de timeout, de healthcheck et de signal forwarding propre.

🧠 Mental model — ASCII + analogie

Pour bien situer le débat, il faut d'abord comprendre que Node.js n'est pas mono-thread au sens strict : le moteur V8 est mono-thread pour l'exécution JavaScript, mais libuv dispose d'un pool de threads (4 par défaut, configurable via UV_THREADPOOL_SIZE) qui exécute en parallèle certaines opérations comme les appels filesystem synchrones côté kernel, les DNS lookups, ou les opérations crypto coûteuses (pbkdf2, scrypt). Ce pool est invisible côté JS — vos await fs.readFile() se résolvent comme par magie sur la boucle, mais ils tournent réellement sur ces threads. C'est pourquoi un service Node n'est pas "naïvement" bloqué par I/O même si la boucle est mono-thread JS.

Le problème survient quand le JS lui-même doit faire du calcul lourd (hashing, parsing, transformation), parce que là, c'est V8 qui doit s'en occuper, et V8 est strictement mono-thread sur la boucle principale. C'est là que worker_threads entre en jeu : créer un autre V8 isolate, dans le même process, avec sa propre boucle d'événements.

L'analogie classique : la boucle d'événements Node est un cuisinier solo dans un restaurant. Il prend les commandes, plonge les pâtes (I/O async), retourne aux clients, sort les pâtes, etc. Tant qu'il n'a pas à hacher un sac d'oignons (CPU-bound), il sert vite. Le jour où une commande demande de hacher 5 kg d'oignons, le service entier s'arrête : c'est le blocage de la boucle.

Trois solutions :

  1. Worker threads = embaucher un commis dans la même cuisine. Il partage les ustensiles (SharedArrayBuffer) mais a son propre plan de travail (V8 isolate). Communication par sas (MessagePort).
  2. child_process = ouvrir une cuisine annexe. Process séparé, OS-level, communication par téléphone (IPC, pipes). Isolation totale, mais latence et coût mémoire.
  3. cluster = cloner toute la cuisine. Plusieurs cuisiniers identiques derrière un même comptoir (le port partagé). Pratique en dev, fragile en prod.
┌─────────────────────────── Node.js process ───────────────────────────┐
│                                                                       │
│  ┌────────── Main thread (event loop) ──────────┐                     │
│  │  V8 isolate principal                        │                     │
│  │  libuv loop ←→ kernel (epoll/kqueue)         │                     │
│  │                                              │                     │
│  │  ┌────── Thread pool libuv (4 par défaut) ─┐ │                     │
│  │  │ fs.*, dns lookup, crypto.pbkdf2, zlib   │ │                     │
│  │  └─────────────────────────────────────────┘ │                     │
│  └──────────────────────────────────────────────┘                     │
│                                                                       │
│  ┌──── Worker #1 ────┐  ┌──── Worker #2 ────┐  ┌──── Worker #3 ────┐  │
│  │ V8 isolate        │  │ V8 isolate        │  │ V8 isolate        │  │
│  │ event loop dédié  │  │ event loop dédié  │  │ event loop dédié  │  │
│  │ ←── MessagePort ──┴──┴─── MessageChannel ─┴──┴─── SAB + Atomics ─┤  │
│  └───────────────────┘  └───────────────────┘  └───────────────────┘  │
└───────────────────────────────────────────────────────────────────────┘

                              vs.

        cluster (HTTP shared port)         child_process (isolation)
        ┌─── primary ───┐                  ┌─── parent ──┐
        │  schedules    │                  │             │
        └──┬──┬──┬──┬───┘                  └──┬──────────┘
           │  │  │  │                         │ IPC pipe (fork)
        worker workers workers              child (sh, python, go)
        (HTTP)                              (any binary)

Le point crucial : un worker_thread partage la mémoire physique du process (SAB), donc on évite la sérialisation pour les gros buffers. Un child_process ne partage rien, on paie la copie. Inversement, un worker qui crash peut potentiellement corrompre le process ; un child crash, c'est juste un code retour.

Sur la question de l'isolation, c'est un spectre :

  • Coroutines/async : un seul V8, un seul event loop, partage complet de la mémoire JS. Idéal pour I/O concurrent, inutile pour CPU.
  • Worker threads : V8 séparés, event loops séparés, partage explicite via SAB. Pour CPU-bound dans le même process.
  • Child process (fork) : process OS séparés, mêmes binaires Node, communication IPC. Pour isoler une logique tout en gardant un canal direct.
  • Child process (spawn/execFile) : process OS séparés, binaire arbitraire. Pour appeler du non-Node.
  • Microservice externe : machine séparée, communication réseau. Pour découplage organisationnel et scaling indépendant.

Chaque niveau ajoute de l'isolation au prix de plus de latence et de complexité. La règle : choisir le niveau le plus léger qui résout le problème.

Table de décision — coût / isolation / partage

MécanismeUnité d'exécutionDémarrageMémoire basePartage mémoireCoût communicationIsolation crashCas d'usage
async/event loop1 V8, 1 loop00heap JS completgratuit (références)aucuneI/O-bound, orchestration
libuv threadpoolN threads kernel0 (préalloué)~négligeabletampons C++ internesinvisibleaucune (partagé)fs, dns, crypto, zlib
worker_threadsV8 isolate + loop30–80 ms30–50 MBexplicite via SharedArrayBufferstructured clone (copie) ou transfer (zero-copy)partielle (un worker peut OOM le process)CPU-bound JS dans le process
child_process.forkprocess Node50–150 ms~30–40 MBaucunIPC JSON (sérialisation)forte (code retour)logique Node isolée + canal
child_process.spawnprocess arbitraire50–200 msdépend du binaireaucunstdio (octets)forteffmpeg, python, Go, CLI
clusterN process Node50–150 ms × N30–40 MB × NaucunIPC + port partagéforte par workerlegacy HTTP multi-core
Microservicemachine/pod séparésecondes (déploiement)indépendantaucunréseau (HTTP/gRPC)totalescaling org. indépendant

Lecture senior de cette table : on monte d'un cran uniquement quand le cran inférieur a un goulot prouvé par la mesure. Le saut le plus coûteux et le plus mal compris est event loop → worker : il introduit la copie (structured clone) comme nouveau coût dominant que les juniors oublient systématiquement, et qui peut annuler tout le gain de parallélisme si on transfère naïvement de gros objets. Le saut worker → child_process se justifie surtout par l'isolation (crash, ulimit/cgroups, binaire non-Node), pas par la performance pure.

🛠️ Code minimal (ts/js)

ts
// workers/basic.ts — un worker qui calcule fibonacci en isolat
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
import { fileURLToPath } from 'node:url';

if (isMainThread) {
  const worker = new Worker(fileURLToPath(import.meta.url), {
    workerData: { n: 42 },
    // execArgv permet de passer des flags V8 spécifiques au worker
    execArgv: ['--max-old-space-size=256'],
  });

  worker.on('message', (result) => console.log('fib(42) =', result));
  worker.on('error', (err) => console.error('worker error', err));
  worker.on('exit', (code) => {
    if (code !== 0) console.error(`worker exited with code ${code}`);
  });
} else {
  // Code exécuté dans le worker
  const fib = (n: number): number => (n < 2 ? n : fib(n - 1) + fib(n - 2));
  parentPort!.postMessage(fib(workerData.n));
}
ts
// workers/message-channel.ts — communication bidirectionnelle via MessageChannel
import { Worker, MessageChannel } from 'node:worker_threads';

const { port1, port2 } = new MessageChannel();
const worker = new Worker('./compute-worker.js');

// On transfère port2 au worker — port1 reste côté main
worker.postMessage({ port: port2 }, [port2]);

port1.on('message', (msg) => console.log('worker says', msg));
port1.postMessage({ type: 'compute', payload: [1, 2, 3] });
ts
// workers/sab-atomics.ts — SharedArrayBuffer + Atomics pour mémoire partagée
import { Worker, isMainThread } from 'node:worker_threads';

const COUNTER_SIZE = 4; // 1 int32
const sab = new SharedArrayBuffer(COUNTER_SIZE);
const counter = new Int32Array(sab);

if (isMainThread) {
  const workers = Array.from({ length: 4 }, () =>
    new Worker(new URL(import.meta.url), { workerData: { sab } })
  );

  setTimeout(() => {
    console.log('Counter final =', Atomics.load(counter, 0));
    workers.forEach((w) => w.terminate());
  }, 500);
} else {
  // Dans chaque worker, on incrémente atomiquement
  for (let i = 0; i < 100_000; i++) {
    Atomics.add(counter, 0, 1);
  }
}
ts
// workers/pool-piscina.ts — pool de workers avec piscina
import Piscina from 'piscina';
import { fileURLToPath } from 'node:url';

const pool = new Piscina({
  filename: fileURLToPath(new URL('./worker-task.js', import.meta.url)),
  minThreads: 2,
  maxThreads: 8,
  idleTimeout: 30_000,
  // queue circulaire bornée — évite l'OOM si l'amont est plus rapide
  maxQueue: 1000,
});

const tasks = Array.from({ length: 100 }, (_, i) => ({ input: i }));
const results = await Promise.all(tasks.map((t) => pool.run(t)));
console.log(`processed ${results.length} tasks`);
await pool.destroy();
ts
// child-process/spawn.ts — spawn streaming d'un binaire arbitraire
import { spawn } from 'node:child_process';

const ffmpeg = spawn('ffmpeg', ['-i', 'in.mp4', '-f', 'mp3', 'pipe:1'], {
  stdio: ['ignore', 'pipe', 'pipe'],
});

ffmpeg.stdout.pipe(process.stdout); // streaming binaire
ffmpeg.stderr.on('data', (chunk) => process.stderr.write(chunk));
ffmpeg.on('close', (code, signal) => {
  console.log(`ffmpeg exited code=${code} signal=${signal}`);
});

// Signal forwarding — propager SIGINT au child
process.on('SIGINT', () => {
  ffmpeg.kill('SIGINT');
});
ts
// child-process/fork-ipc.ts — fork pour script Node + canal IPC
import { fork } from 'node:child_process';

const child = fork('./child-script.js', [], {
  stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
  env: { ...process.env, CHILD: '1' },
});

child.send({ type: 'job', payload: { batch: 1 } });
child.on('message', (msg) => console.log('child →', msg));
child.on('exit', (code) => console.log('child exited', code));
ts
// child-process/exec-vs-execFile.ts — pourquoi exec est dangereux
import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';

const execP = promisify(exec);
const execFileP = promisify(execFile);

// MAUVAIS : injection shell si userInput contient `; rm -rf /`
const userInput = 'foo';
await execP(`ls ${userInput}`); // ⚠️

// BON : pas de shell, arguments isolés
await execFileP('ls', [userInput]); // sûr
ts
// child-process/abort-timeout.ts — timeout via AbortController
import { spawn } from 'node:child_process';

const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 5000);

const child = spawn('long-running-binary', ['--input', 'data.bin'], {
  signal: ac.signal,
  stdio: ['ignore', 'pipe', 'pipe'],
});

child.on('error', (err) => {
  if (err.name === 'AbortError') console.error('killed by timeout');
});
child.on('close', () => clearTimeout(timer));
ts
// workers/transferable-buffer.ts — éviter la copie d'un gros Buffer
import { Worker } from 'node:worker_threads';

const buf = new ArrayBuffer(50 * 1024 * 1024); // 50 MB
const view = new Uint8Array(buf);
// remplir buf avec de la donnée ...

const worker = new Worker('./image-processor.js');
// Transfer — buf devient inutilisable côté main, zéro copie
worker.postMessage({ image: buf }, [buf]);

// Après transfer, buf.byteLength === 0 côté main
console.log(buf.byteLength); // 0
ts
// workers/atomics-wait.ts — synchronisation type mutex avec Atomics
import { Worker, isMainThread, workerData } from 'node:worker_threads';

const sab = new SharedArrayBuffer(8);
const lock = new Int32Array(sab); // index 0: lock state (0/1), index 1: counter

if (isMainThread) {
  Array.from({ length: 4 }, () => new Worker(new URL(import.meta.url), {
    workerData: { sab },
  }));
} else {
  const lock = new Int32Array(workerData.sab);
  for (let i = 0; i < 1000; i++) {
    // spin until lock acquired
    while (Atomics.compareExchange(lock, 0, 0, 1) !== 0) {
      Atomics.wait(lock, 0, 1, 10); // attendre max 10 ms
    }
    // section critique
    lock[1] += 1;
    // release
    Atomics.store(lock, 0, 0);
    Atomics.notify(lock, 0, 1);
  }
}
ts
// cluster/legacy.ts — pour info, le mode cluster historique
import cluster from 'node:cluster';
import os from 'node:os';
import http from 'node:http';

if (cluster.isPrimary) {
  console.log(`Primary ${process.pid} starting ${os.cpus().length} workers`);
  for (let i = 0; i < os.cpus().length; i++) cluster.fork();

  cluster.on('exit', (worker, code, signal) => {
    console.warn(`Worker ${worker.process.pid} died (${signal || code})`);
    cluster.fork(); // respawn
  });
} else {
  http.createServer((req, res) => {
    res.end(`Hello from worker ${process.pid}\n`);
  }).listen(3000);
}

🎯 Patterns courants

1. Offload CPU-bound vers un worker pool. Quand on a une route HTTP qui doit hasher un mot de passe (bcrypt, argon2), parser un PDF, transformer une image avec sharp, compiler une regex complexe, exécuter du machine learning sur tensorflow.js, ou compresser un payload avec brotli, on ne le fait jamais sur la boucle principale. Un pool piscina de taille Math.max(1, os.cpus().length - 1) est un bon défaut. On garde toujours un cœur pour la boucle principale qui orchestre. Sous Kubernetes avec une limite CPU de 2000m, on tournera typiquement avec 2 workers + 1 main. Le pool absorbe les pics, le main reste réactif.

2. Transfer vs structured clone. Si on envoie un ArrayBuffer ou un MessagePort, on peut les passer en deuxième argument de postMessage pour les transférer : ils deviennent inutilisables côté émetteur, mais on évite la copie. Pour des objets profonds (JSON-like), c'est le structured clone qui s'applique automatiquement, et ça peut coûter cher : un objet de 50 Mo passe 200-500 ms en sérialisation. Le structured clone gère récursivement, supporte les Map, Set, Date, RegExp, mais pas les fonctions ni les prototypes custom (qui sont dégradés en plain object). Une astuce : sérialiser une seule fois côté main en JSON String, transférer le buffer (encoder en UTF-8 typedArray), désérialiser côté worker. Ça paraît contre-intuitif mais c'est plus rapide qu'un structured clone profond, parce que JSON.parse/stringify est ultra-optimisé en C++ V8.

3. SharedArrayBuffer pour la mémoire partagée. Quand plusieurs workers doivent partager un grand tableau (image, matrice numérique, ring buffer audio), on alloue un SharedArrayBuffer côté main, on passe la référence à chaque worker, et on utilise Atomics pour les opérations concurrentes. Pour les attentes bloquantes côté worker, Atomics.wait / Atomics.notify permettent une synchronisation type mutex sans busy-loop. Attention : Atomics.wait n'est pas autorisé sur le main thread (jetterait une exception) parce qu'il bloque la boucle d'événements. C'est une caractéristique exclusive aux workers. Les opérations atomiques supportées sont add, sub, and, or, xor, load, store, exchange, compareExchange. Pour des structures plus complexes (queues, stacks), il faut composer ces primitives avec soin — ou utiliser une lib comme atomic-queue qui implémente les algorithmes lock-free classiques.

4. Cluster avec orchestrateur externe. Le module cluster est encore là, mais documente lui-même qu'il ne gère pas le rolling restart, le zero-downtime, ni l'observabilité. En production, on lance N processus identiques via PM2, systemd ou Kubernetes, chacun écoutant le même port avec SO_REUSEPORT (Linux) ou derrière un load balancer. L'orchestrateur s'occupe du restart-on-crash, de la rotation, des healthchecks.

5. child_process pour appeler du non-Node. Convertir une image avec ImageMagick, lancer un script Python ML, appeler un binaire Go : on utilise spawn avec stdio: 'pipe'. On streame, on ne bufferise pas (sinon OOM si le child produit beaucoup). On gère close, error, et le timeout via AbortController. La règle d'or : ne jamais utiliser exec avec une chaîne construite à partir d'input utilisateur. Toujours préférer execFile ou spawn avec un tableau d'arguments, qui ne passent pas par un shell intermédiaire. Si on a vraiment besoin d'un shell (pour des wildcards, des pipes), passer shell: true mais sanitizer agressivement chaque argument — et de toute façon, préférer reconstruire la logique en JS ou avec des binaires intermédiaires.

6. Signal forwarding et shutdown propre. Dans un process parent, on écoute SIGTERM/SIGINT et on les propage aux workers/children. Chaque worker a son propre handler qui draine ses tâches en cours, ferme ses connexions, puis exit 0. Si le drainage dépasse N secondes, on force SIGKILL. C'est ce qu'attend Kubernetes pour un pod terminé proprement.

7. Daemon patterns. Pour un daemon Node (worker en arrière-plan), on évite detach: true sauf vrai besoin de double-fork Unix. On préfère systemd ou un manager qui owne le cycle de vie. Stdout/stderr toujours vers un logger structuré ; on ne s'appuie jamais sur un fichier de log géré manuellement.

8. Worker lifecycle — preCreate. Un worker met 30-80 ms à démarrer (V8 isolate + module loading). Pour des tâches latency-sensitive, on pré-crée le pool au boot et on garde les workers chauds. piscina le fait par défaut avec minThreads.

9. Canary worker. Un worker dédié au CPU-bound qu'on garde isolé pour observer ses performances individuellement. Si le canary lag, on sait que le code CPU s'est dégradé sans regarder l'agrégat.

10. Bound queue. Toujours borner la file d'attente devant un pool. Si l'amont est plus rapide que la consommation, une file non bornée explose la mémoire silencieusement. piscina accepte maxQueue ; un dépassement renvoie une erreur que l'amont doit traduire en 503/backpressure.

11. Worker errors et recovery. Un worker peut crash sur une exception non gérée. Il faut écouter worker.on('error', ...) et worker.on('exit', code => code !== 0). Sur crash, le pattern recovery dépend du contexte : un pool comme piscina respawn automatiquement le worker mort. Pour un worker custom, il faut le réinstancier soi-même, idéalement après un délai exponentiel pour éviter une boucle de crash. Logger le crash avec un identifiant de worker pour pouvoir corréler avec les requêtes en cours.

12. IPC structuré entre parent et child. Quand on fork() un script Node, le canal IPC accepte n'importe quel objet sérialisable JSON. Bonne pratique : définir un protocole de messages avec un champ type et un schéma Zod/Joi validé des deux côtés. Sans ça, un changement de format côté parent casse silencieusement le child. On peut aussi versionner le protocole pour gérer les rolling updates.

13. Backpressure côté child_process streaming. Si le parent lit child.stdout plus lentement que le child n'écrit, le buffer interne se remplit jusqu'au highWaterMark, puis le child se bloque en écriture (côté kernel). C'est généralement OK, mais peut causer des deadlocks si le child attend aussi de lire stdin. Toujours utiliser pipeline() qui gère la backpressure proprement.

🔄 Versions — Node 18 / 20 / 22 / 24

Node 18 (LTS — sortie EOL avril 2025). worker_threads stable depuis Node 12. fetch global expérimental, utilisable dans les workers. --experimental-vm-modules. Les tests internes étaient encore expérimentaux. cluster toujours là mais déjà recommandé d'utiliser un orchestrateur externe.

Node 20 (LTS). node:test stable, on peut tester directement workers et child_process avec le runner natif. --watch mode pour le dev des workers. permission model expérimental qui permet de restreindre ce qu'un child peut faire (lecture fs, env). WebStreams désormais matures dans les workers.

Node 22 (LTS). ESM par défaut dans plus de cas, require(esm) expérimental qui simplifie l'écriture de workers en mélange CJS/ESM. node --run pour exécuter des scripts package.json. WebSocket global. Le permission model se stabilise. Améliorations sur la latence de démarrage des workers.

Node 24 (Current). Performances V8 améliorées sur le démarrage de worker (~15-20 % plus rapide selon les workloads). node:test avec reporter TAP14 et coverage natif (V8 coverage) intégré. URLPattern global, utile pour router dans des workers HTTP. Les snapshots utilisateur (--build-snapshot) accélèrent encore le cold start. Le cluster module n'est pas formellement déprécié mais reçoit zéro feature nouvelle ; toute la documentation pousse vers les orchestrateurs externes.

Sur les API MessagePort / MessageChannel : ces classes implémentent désormais l'interface Web standard et sont utilisables côté browser comme côté Node, ce qui facilite le portage de code isomorphic (par exemple un parser qui tourne en Web Worker ou en worker thread sans changer).

⚠️ Pitfalls — 6-10

1. Croire qu'un worker accélère du I/O-bound. Si votre route fait des fetch et du pg.query, la boucle principale ne bloque pas. Lancer un worker ne fera qu'ajouter le coût du structured clone et de la copie. Les workers sont pour du CPU-bound.

2. Pool sous-dimensionné ou sur-dimensionné. Trop peu de workers = file qui s'allonge. Trop = thrashing CPU et explosion mémoire (chaque worker = ~30-50 Mo de base V8). Régler maxThreads à os.cpus().length - 1 est un bon point de départ.

3. Oublier de transférer un gros buffer. Passer un Buffer de 100 Mo en postMessage sans le mettre dans le tableau de transfer = copie complète, 500 ms de pause sur le main thread. Toujours transferList-er les ArrayBuffer.

4. Race condition avec SharedArrayBuffer sans Atomics. Lire/écrire un Int32Array partagé sans Atomics.load/Atomics.store donne des résultats indéfinis (V8 peut réorganiser). Atomics n'est pas optionnel, c'est le contrat mémoire.

5. exec avec input utilisateur. child_process.exec(grep ${userQuery} file) est une faille de sécurité majeure. Utiliser execFile ou spawn avec un tableau d'arguments. Et sanitizer même là si l'argument peut être interprété par le binaire cible.

6. Cluster sans graceful shutdown. Un cluster.fork() qui meurt sans drainer ses requêtes coupe les connexions HTTP en cours. Côté worker, écouter SIGTERM, fermer le serveur, attendre les requêtes actives, puis exit.

7. Stdout/stderr non drainés sur un child. Un child qui écrit beaucoup sur stdout sans qu'on lise = pipe pleine = le child bloque sur l'écriture. Toujours soit pipe + lecture, soit ignore, soit inherit. Jamais une pipe abandonnée.

8. Modules CJS qui dépendent du main thread. Certains modules natifs (better-sqlite3, sharp) initialisent des ressources globales au require. Les utiliser dans un worker fonctionne, mais peut doubler la mémoire si on a 8 workers. Vérifier que le module supporte explicitement les workers.

9. Worker qui ne meurt pas. Si on n'appelle pas worker.terminate() et que le worker garde un setInterval, le process parent ne sortira jamais. Toujours tracker les workers et les terminer au shutdown.

10. Confondre fork (child_process) et fork Unix. child_process.fork n'est pas fork(2) POSIX — c'est un alias de spawn qui lance un autre script Node et établit un canal IPC. Pour le vrai fork Unix, il faut un addon natif (rarement nécessaire).

11. process.env partagé mais figé. Un worker thread reçoit une copie de process.env au démarrage. Modifier process.env après spawn du worker n'affecte pas le worker. Pour passer de la config dynamique, utiliser workerData ou les messages.

12. Worker qui ne peut pas accéder au DOM ou aux APIs main-only. Si on porte du code browser, certaines APIs ne sont pas disponibles dans un worker thread (par exemple, certains modules Node qui s'attendent à être sur le main thread). Tester chaque dépendance.

13. Path d'entrée du worker. Un worker démarre dans le même CWD que le parent, mais le chemin de fichier passé doit être absolu (URL ou chemin) — un chemin relatif est résolu par rapport au CWD du parent, ce qui peut surprendre en bundling.

14. Cluster + sticky session. Le module cluster round-robine les connexions par défaut. Pour des WebSockets ou des sessions stateful avec sticky, il faut une couche supplémentaire (sticky-session lib, ou load balancer externe avec sticky cookie).

🧪 Testing — node --test, benchmarks

ts
// tests/worker.test.ts — tester un worker avec node:test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Worker } from 'node:worker_threads';
import { fileURLToPath } from 'node:url';

test('worker computes fib correctly', async () => {
  const worker = new Worker(
    fileURLToPath(new URL('../src/fib-worker.js', import.meta.url)),
    { workerData: { n: 10 } }
  );

  const result = await new Promise<number>((resolve, reject) => {
    worker.once('message', resolve);
    worker.once('error', reject);
  });

  await worker.terminate();
  assert.equal(result, 55);
});

test('worker respects timeout via AbortSignal', async () => {
  const ac = new AbortController();
  const worker = new Worker('./long-running.js');

  setTimeout(() => worker.terminate(), 50);
  const exitCode = await new Promise<number>((resolve) =>
    worker.once('exit', resolve)
  );
  assert.notEqual(exitCode, 0);
});
bash
# Benchmark — comparer main thread vs worker pool
node --test tests/
node bench/fib-comparison.js
# Avec autocannon pour mesurer l'impact sur une route HTTP
npx autocannon -c 50 -d 30 http://localhost:3000/hash
ts
// bench/pool-vs-direct.ts — mesurer l'effet d'un pool
import Piscina from 'piscina';
import { performance } from 'node:perf_hooks';

const pool = new Piscina({ filename: './heavy.js', maxThreads: 4 });

const N = 1000;
const t0 = performance.now();
await Promise.all(Array.from({ length: N }, (_, i) => pool.run({ i })));
console.log('pool', performance.now() - t0, 'ms');

// vs. inline (bloquant pour la boucle)
const t1 = performance.now();
for (let i = 0; i < N; i++) heavyInline(i);
console.log('inline', performance.now() - t1, 'ms');

Pour les tests de child_process, on utilise spawn avec un script de fixture, on assert sur les codes de sortie, les flux stdout/stderr capturés, et on couvre le cas du timeout via AbortController ou un kill() manuel après délai.

ts
// tests/child-process.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';

test('child receives SIGINT and exits cleanly', async () => {
  const child = spawn(process.execPath, ['./fixtures/long-running.js']);

  const exitPromise = new Promise<number>((resolve) => {
    child.once('exit', resolve);
  });

  // Laisser démarrer puis envoyer SIGINT
  await new Promise((r) => setTimeout(r, 100));
  child.kill('SIGINT');

  const code = await exitPromise;
  // Le child doit avoir trappé SIGINT et exit 0
  assert.equal(code, 0);
});

test('child fails fast on bad input', async () => {
  const child = spawn(process.execPath, ['./fixtures/parser.js', '--malformed']);

  let stderr = '';
  child.stderr.on('data', (chunk) => (stderr += chunk));

  const code = await new Promise<number>((r) => child.once('exit', r));
  assert.notEqual(code, 0);
  assert.match(stderr, /malformed input/i);
});

Pour les benchmarks comparatifs, on mesure trois scénarios : (a) la tâche exécutée inline sur la boucle (baseline, mais bloque la boucle), (b) la même tâche avec un pool piscina (overhead du pool mais boucle libre), (c) la tâche avec un child_process (overhead OS). On surveille simultanément l'event loop lag avec monitorEventLoopDelay pour voir l'impact sur la réactivité. Sur un workload type hashing bcrypt avec coût 12, on observe typiquement : inline = boucle bloquée 400 ms par opération, pool = boucle libre, latence p99 +20 % par rapport au calcul brut, child_process = boucle libre, mais latence p99 +200 % à cause du startup OS.

🎬 Cas d'usage concrets

Scénario 1 — OCR cabinet juridique pool workers

Cabinet ingérant 3000 actes scannés/nuit pour un audit GED. OCR via tesseract.js (binding WebAssembly) sur chaque page : 200 à 800 ms CPU par page. Le worker BullMQ qui consomme la file ne peut pas faire ça sur le main thread — il bloquerait le heartbeat Redis.

Architecture : main BullMQ worker dépile un job, délègue chaque page à un piscina pool (taille = cpus().length - 1, soit 7 sur 8 vCPU). Concurrency BullMQ = 4 jobs en parallèle ; chaque job a en moyenne 30 pages, donc le pool est saturé en permanence. piscina gère le routing round-robin et la backpressure (queue interne).

Mesures : sans worker pool, 30 pages/s. Avec pool de 7, 180 pages/s. Coût mémoire : ~120 MB par worker (tesseract trained data en mémoire), gardés warm via idleTimeout: Infinity plutôt que respawnés à chaque job.

Scénario 2 — Image processing e-commerce

Plateforme e-commerce avec endpoint /products/:id/images qui accepte upload vendeur et génère 4 variantes (thumb 200, medium 600, large 1200, AVIF 1200). Sharp/libvips est CPU-bound natif — ne bloque pas la boucle Node directement (libuv threadpool), mais bloque les threads libuv pour les autres opérations FS/DNS.

Solution : worker_threads dédiés au pipeline image, pas libuv. Pool fixe (4 workers), buffers passés en transferList (zéro copie depuis le main vers le worker). Le main thread reste dispo pour DNS/FS/network sans contention sur les threads libuv (UV_THREADPOOL_SIZE=4 par défaut, vite saturé sinon).

Bench : sans workers, 80 % des requêtes simultanées voyaient leur DNS lookup ralentir car libuv threadpool était occupé par sharp. Avec workers dédiés, P99 DNS stable même sous charge image lourde.

Scénario 3 — Calcul taux banque CPU-bound

Banque qui calcule un échéancier de prêt avec taux variable indexé Euribor + simulation Monte Carlo (10 000 scénarios par demande pour stress-test). Chaque simulation = 80 ms CPU. Sur un endpoint synchrone, c'est inacceptable (P99 > 1s).

worker_threads avec SharedArrayBuffer pour la matrice des scénarios (lecture seule, 4 MB, partagée entre tous les workers du pool, économie mémoire par rapport à transferList qui copie). Chaque worker simule 1250 scénarios (10000 / 8) puis renvoie son agrégat. Le main combine les 8 agrégats avant de retourner la réponse.

Latence finale : 110 ms (vs 800 ms en single-thread). Le SharedArrayBuffer est créé une fois au boot (matrice Euribor historique) et conservé tant que le service tourne — pas re-transmis à chaque request.

🛠️ Exemple end-to-end

Service de hash bcrypt + worker pool piscina-style maison, avec graceful shutdown, métriques, et gestion d'erreurs propagées.

ts
// main.ts
import { Worker } from "node:worker_threads";
import { cpus } from "node:os";
import { createServer } from "node:http";
import { performance } from "node:perf_hooks";
import { fileURLToPath } from "node:url";

type Job<I = unknown, O = unknown> = { input: I; resolve: (o: O) => void; reject: (e: Error) => void };

class Pool<I, O> {
  private idle: Worker[] = [];
  private busy = new Set<Worker>();
  private queue: Job<I, O>[] = [];
  private stats = { processed: 0, errors: 0 };

  constructor(private script: string, private size = Math.max(1, cpus().length - 1)) {
    for (let i = 0; i < size; i++) this.idle.push(this.spawn());
  }

  private spawn(): Worker {
    const w = new Worker(this.script);
    w.on("error", (err) => {
      this.stats.errors++;
      console.error("[pool] worker error", err);
      this.busy.delete(w);
      // self-heal
      this.idle.push(this.spawn());
      this.drain();
    });
    return w;
    // ⚠️ Faille volontaire pour l'exercice 4 : si un worker crash EN COURS de job,
    // la promesse du job en vol n'est jamais rejetée (le listener onMsg meurt avec
    // le worker) → fuite + requête HTTP qui pend jusqu'au timeout serveur. Une
    // implémentation production-grade doit tracker quel job est sur quel worker
    // et rejeter ce job dans le handler 'error'/'exit'.
  }

  private drain() {
    while (this.idle.length && this.queue.length) {
      const w = this.idle.pop()!;
      const job = this.queue.shift()!;
      this.busy.add(w);
      const onMsg = (msg: { ok: true; result: O } | { ok: false; error: string }) => {
        w.off("message", onMsg);
        this.busy.delete(w);
        this.idle.push(w);
        this.stats.processed++;
        if (msg.ok) job.resolve(msg.result);
        else job.reject(new Error(msg.error));
        this.drain();
      };
      w.on("message", onMsg);
      w.postMessage(job.input);
    }
  }

  run(input: I): Promise<O> {
    return new Promise((resolve, reject) => {
      this.queue.push({ input, resolve, reject });
      this.drain();
    });
  }

  metrics() {
    return { ...this.stats, queued: this.queue.length, busy: this.busy.size, idle: this.idle.length };
  }

  async destroy() {
    await Promise.all([...this.idle, ...this.busy].map((w) => w.terminate()));
  }
}

const pool = new Pool<{ password: string; cost: number }, string>(
  // fileURLToPath, pas .pathname : gère Windows et les espaces dans le chemin
  fileURLToPath(new URL("./bcrypt-worker.js", import.meta.url))
);

const server = createServer(async (req, res) => {
  if (req.url === "/metrics") {
    res.setHeader("content-type", "application/json");
    return res.end(JSON.stringify(pool.metrics()));
  }
  if (req.method !== "POST" || req.url !== "/hash") {
    res.statusCode = 404;
    return res.end();
  }
  const chunks: Buffer[] = [];
  for await (const c of req) chunks.push(c);
  const { password } = JSON.parse(Buffer.concat(chunks).toString());
  const t0 = performance.now();
  try {
    const hash = await pool.run({ password, cost: 12 });
    res.setHeader("x-elapsed-ms", (performance.now() - t0).toFixed(1));
    res.end(JSON.stringify({ hash }));
  } catch (err) {
    res.statusCode = 500;
    res.end(JSON.stringify({ error: (err as Error).message }));
  }
});

server.listen(3000);

for (const sig of ["SIGTERM", "SIGINT"] as const) {
  process.on(sig, async () => {
    server.close();
    await pool.destroy();
    process.exit(0);
  });
}
ts
// bcrypt-worker.ts
import { parentPort } from "node:worker_threads";
import bcrypt from "bcrypt";

parentPort!.on("message", async ({ password, cost }: { password: string; cost: number }) => {
  try {
    const hash = await bcrypt.hash(password, cost);
    parentPort!.postMessage({ ok: true, result: hash });
  } catch (err) {
    parentPort!.postMessage({ ok: false, error: (err as Error).message });
  }
});

Points clés : pool générique typé, self-heal sur error, /metrics exposé pour Prometheus, graceful shutdown (server.close puis pool.destroy), backpressure naturelle via la queue interne, bcrypt n'utilise plus le main thread donc l'event-loop reste libre pour le routing HTTP.


🔁 Quand utiliser / éviter

Utiliser worker_threads quand :

  • Une opération CPU-bound dépasse 50 ms et bloque la boucle d'événements.
  • On a besoin de partager des buffers larges sans copie (SAB).
  • On veut paralléliser du calcul numérique, parsing, compression.
  • Le code est isomorphic (web worker compatible) et on veut le réutiliser.

Éviter worker_threads quand :

  • C'est du pur I/O — la boucle gère déjà très bien.
  • L'opération est < 5 ms — le coût de message + serialization dépasse le gain.
  • On veut isoler la sécurité (un worker partage le process, pas de sandbox).

Utiliser child_process quand :

  • On lance un binaire externe (ffmpeg, ImageMagick, python).
  • On veut une isolation forte (crash du child ne tue pas le parent).
  • On a besoin de quotas OS distincts (ulimit, cgroups).
  • On scripte de l'outillage CLI depuis Node.

Éviter child_process quand :

  • L'overhead OS (fork + exec, ~50-200 ms) est inacceptable.
  • On échange des giga-octets de données (la copie IPC est coûteuse).

Utiliser cluster quand :

  • Prototype rapide en local sans orchestrateur.
  • Migration progressive depuis un monolithe single-process.

Éviter cluster en prod : un orchestrateur externe (PM2, systemd, k8s) gère mieux le restart, l'observabilité, les rolling updates, et la gestion des signaux.

Critères de décision rapides. Une tâche < 5 ms qui apparaît parfois : laisser sur la boucle. Entre 5 et 50 ms occasionnellement : envisager setImmediate pour la fractionner. Entre 50 et 500 ms régulière : worker pool obligatoire. Plus de 500 ms ou besoin d'un binaire externe : child_process. Pour du calcul lourd en batch (offline), on peut même considérer une queue type BullMQ qui externalise vers d'autres processus, voire d'autres machines.

Multi-process vs multi-thread. En Node, les workers sont des threads mais ont chacun leur V8 isolate — pas de partage de heap, contrairement aux threads "vrais" en Java/Go. Cela simplifie le modèle mental (pas de race condition sur le heap JS, seulement sur les SAB explicites) mais le coût mémoire d'un worker est élevé (30-50 MB minimum). Pour 50 workers, on parle de 1.5-2.5 GB rien qu'en overhead. Les child_process ont un coût similaire mais avec une isolation OS complète.

Microservices vs workers. À grand échelle, certaines équipes préfèrent extraire les jobs CPU-bound dans un microservice séparé (Go, Rust, Python avec uvloop), communiquant via HTTP/gRPC. C'est plus de complexité opérationnelle, mais c'est un meilleur ROI quand le job est dominant dans la charge et qu'on peut le scaler indépendamment du front HTTP.

🏋️ Exercices

Chaque exercice est conçu pour être tournable en local avec Node 20+. Ils escaladent : implémenter → durcir pour la prod → casser puis réparer.

Exercice 1 — Offload bcrypt sans bloquer la boucle (échauffement)

Objectif : prouver, mesure à l'appui, qu'un hash bcrypt cost 12 bloque la boucle inline et ne la bloque plus via worker.

Construis une route /hash en deux variantes (inline vs piscina). Pendant la charge, mesure l'event loop lag avec monitorEventLoopDelay (perf_hooks) et expose p99 du lag sur /lag. Lance autocannon -c 50 -d 20 sur chaque variante et compare le lag.

Indice/Solution : const h = monitorEventLoopDelay({ resolution: 10 }); h.enable(); puis h.percentile(99) / 1e6 pour des millisecondes. Attendu : inline → lag p99 de l'ordre de centaines de ms (la boucle reste figée pendant chaque hash) ; pool → lag p99 de quelques ms. Le débit brut peut être proche, mais la réactivité (lag) diffère d'un ordre de grandeur — c'est tout l'argument.

Exercice 2 — Transfer vs structured clone, le coût caché

Objectif : quantifier le coût du structured clone et trouver le point de bascule où JSON.stringify + transfer bat la copie native.

Génère un objet imbriqué de ~50 Mo. Mesure le round-trip main→worker→main pour : (a) postMessage(obj) (structured clone), (b) postMessage(buf, [buf]) après Buffer.from(JSON.stringify(obj)) côté main + JSON.parse côté worker. Trace les deux temps en fonction de la taille (1, 5, 10, 50 Mo).

Indice/Solution : le structured clone est récursif et alloue ; pour des objets larges et "plats en données" (tableaux de nombres, strings), JSON.stringify en C++ V8 + transfer du ArrayBuffer sous-jacent (zero-copy) gagne souvent au-delà de quelques Mo. Pour des données purement numériques, la vraie réponse est un Float64Array sur SharedArrayBuffer : zéro sérialisation. L'enseignement : le "bon" transport dépend de la forme de la donnée, pas d'une règle absolue.

Exercice 3 — Pool borné avec backpressure 503 (production-grade)

Objectif : transformer un pool naïf en composant prod : file bornée, rejet propre, métriques, warmup.

Étends le pool maison du chapitre avec : maxQueue (rejet → l'appelant doit renvoyer 503 Retry-After), minThreads chauds au boot, et un endpoint /metrics au format Prometheus (pool_queue_depth, pool_busy_workers, pool_jobs_total, pool_errors_total). Vérifie qu'une rafale dépassant maxQueue renvoie des 503 et non des OOM.

Indice/Solution : dans run(), si this.queue.length >= this.maxQueue, reject(new PoolOverloadError()) immédiatement ; le handler HTTP mappe cette erreur en 503 + header Retry-After: 1. Pour le warmup, await Promise.all(workers.map(w => w.ready)) où chaque worker poste un { ready: true } après chargement de ses dépendances lourdes. Test de charge : autocannon -c 500 doit produire un mix 200/503, jamais un crash mémoire ni une latence qui diverge.

Exercice 4 — Casse puis répare : le job perdu sur crash worker (break-then-fix)

Objectif : reproduire la faille signalée dans le code end-to-end (job en vol jamais rejeté sur crash worker), puis la corriger.

Dans le worker, ajoute if (password === 'boom') process.exit(1). Envoie une requête boom : observe que la requête HTTP pend indéfiniment (la promesse n'est ni résolue ni rejetée). Corrige : associe chaque worker au job qu'il traite (Map<Worker, Job>), et dans les handlers error ET exit (code !== 0), rejette le job en vol avant de respawn.

Indice/Solution : remplace l'onMsg anonyme par un current?: Job stocké sur le worker. error/exitif (current) current.reject(new WorkerCrashError(...)), retire le worker des sets, respawn, drain(). Ajoute un AbortController/timeout côté handler HTTP comme deuxième filet (le client ne doit jamais dépendre uniquement de la santé du worker). Test : la requête boom doit renvoyer 500 en < 100 ms, et le pool doit revenir à idle.length === size après respawn.

Exercice 5 — Mutex lock-free sur SharedArrayBuffer (hardcore)

Objectif : implémenter un compteur partagé correct entre 8 workers et démontrer la corruption sans Atomics.

Lance 8 workers incrémentant 1 000 000 fois un Int32Array partagé. Variante A : arr[0]++ (non atomique). Variante B : Atomics.add(arr, 0, 1). Compare le résultat final aux 8_000_000 attendus. Puis implémente un spinlock avec compareExchange + Atomics.wait/notify protégeant une section critique multi-mots (deux compteurs à garder cohérents).

Indice/Solution : la variante A donnera un total inférieur et non déterministe (écritures perdues). Atomics.add corrige le compteur simple. Pour la section critique multi-mots, while (Atomics.compareExchange(lock, 0, 0, 1) !== 0) Atomics.wait(lock, 0, 1); à l'entrée, Atomics.store(lock, 0, 0); Atomics.notify(lock, 0, 1); à la sortie. Piège à mesurer : Atomics.wait est interdit sur le main thread — fais tourner le bench uniquement dans les workers. Bonus : remplace le spinlock par un Atomics.waitAsync (Node 16+) pour ne pas bloquer une loop de worker qui fait aussi de l'I/O.

Exercice 6 — Pipeline child_process avec backpressure et timeout (intégration)

Objectif : streamer une transformation via un binaire externe, sans OOM, avec annulation propre.

Lance ffmpeg (ou cat d'un gros fichier pour simuler) via spawn, pipe stdout vers un fichier via pipeline(). Impose un timeout 5 s via AbortController ; vérifie que sur abort le child reçoit SIGTERM puis SIGKILL si récalcitrant, et que les descripteurs sont libérés. Simule un consommateur lent et prouve que la backpressure bloque le child sans gonfler la RAM.

Indice/Solution : spawn(bin, args, { signal: ac.signal, stdio: ['ignore','pipe','pipe'] }) ; await pipeline(child.stdout, slowWritable). Pour le double-kill : sur abort, child.kill('SIGTERM') puis setTimeout(() => child.kill('SIGKILL'), graceMs) annulé sur exit. Surveille la RSS (process.memoryUsage().rss) côté parent pendant un consommateur lent : elle doit rester plate (le kernel applique la backpressure sur la pipe), preuve qu'on ne bufferise pas en mémoire JS — contrairement à exec qui, lui, accumulerait tout jusqu'au maxBuffer puis exploserait.

🎤 En entretien

Q : Pourquoi un worker_thread qui fait du fetch n'apporte rien, alors qu'un worker_thread qui fait du bcrypt change tout ? R : fetch est I/O-bound — la boucle principale ne bloque jamais dessus (libuv/kernel font le travail pendant que la loop sert d'autres requêtes) ; déporter ça dans un worker n'ajoute que le coût de structured clone. bcrypt est CPU-bound JS/natif sur la loop : il fige V8 plusieurs centaines de ms. Le worker libère la loop. Règle : worker = CPU-bound, jamais I/O-bound.

Q : transfer vs structured clone — quand l'un, quand l'autre, et quel est le piège ? R : transfer (2ᵉ arg de postMessage) déplace la propriété d'un ArrayBuffer/MessagePort sans copie (zero-copy), mais l'émetteur perd l'accès (byteLength === 0). structured clone copie en profondeur (Map/Set/Date OK, fonctions/prototypes non) et coûte O(taille) — un objet 50 Mo, c'est 200–500 ms de pause sur l'émetteur. Piège : passer un gros Buffer sans transferList déclenche une copie silencieuse qui bloque le main. Pour de la donnée vivant des deux côtés, c'est SharedArrayBuffer qu'il faut, pas transfer.

Q : Pourquoi Atomics.wait est-il interdit sur le main thread, et qu'est-ce que ça implique pour le design ? R : Atomics.wait bloque le thread appelant jusqu'à notify ou timeout. Sur le main thread, ça gèlerait la boucle d'événements (plus de I/O, plus de timers, plus de réponses HTTP) — V8 jette donc une exception. Implication : toute synchronisation bloquante de type mutex doit vivre dans les workers ; côté main on utilise les messages, ou Atomics.waitAsync qui retourne une promesse au lieu de bloquer.

Q : Pourquoi le module cluster est-il déconseillé en prod alors qu'il « marche » ? R : cluster partage un port entre N workers Node mais ne fournit ni rolling restart zero-downtime, ni healthcheck, ni rotation observable, ni gestion fine des signaux — la doc Node le dit elle-même. Un orchestrateur externe (k8s, systemd, PM2) fait tout ça mieux, et chaque process redémarre indépendamment avec liveness/readiness probes. cluster reste acceptable pour un proto local ou une migration progressive, pas pour un service critique.

Q : Un de tes workers du pool crash en plein job. Que se passe-t-il par défaut, et comment tu rends ça correct ? R : Par défaut, si le pool n'associe pas explicitement le job au worker, la promesse du job en vol n'est jamais réglée → la requête HTTP pend jusqu'au timeout serveur, fuite mémoire incluse. Correct : tracker Map<Worker, Job>, et dans les handlers error/exit (code !== 0) rejeter le job courant (WorkerCrashError), retirer le worker, respawn avec backoff exponentiel contre les crash-loops, et garder un timeout/AbortController côté appelant comme filet indépendant de la santé du worker.

🔗 Liens


Récapitulatif

Trois mécanismes, trois usages distincts. worker_threads pour le CPU-bound dans le même process, avec partage mémoire fin (SAB + Atomics) et communication via MessagePort. child_process pour appeler du non-Node ou isoler complètement, avec quatre variantes (spawn bas niveau, exec dangereux, execFile sûr, fork pour Node→Node). cluster survit pour du legacy mais en production on délègue à un orchestrateur. Toujours dimensionner les pools (maxThreads ≈ cores - 1), borner les files, propager les signaux, et mesurer avant d'optimiser : un worker mal placé ralentit plus qu'il n'accélère. La règle d'or : un worker n'est utile que pour une tâche qui bloquerait visiblement la boucle. Tout le reste, c'est du folklore.

Sur les signaux et le shutdown : tous les workers et children doivent écouter SIGTERM/SIGINT, draîner leurs tâches en cours, libérer les ressources externes (handles DB, fichiers, sockets), puis exit 0. Le parent fait de même et propage les signaux. Sans cette discipline, on a des connexions HTTP coupées, des transactions DB orphelines, et des Kubernetes pods qui montrent comme "terminating" pendant la durée de grace puis SIGKILL violent. En pratique, prévoir un budget de drainage de 15-30 secondes par worker, et synchroniser ça avec terminationGracePeriodSeconds du pod.

Sur l'observabilité : chaque worker doit exporter ses propres métriques (event loop lag, mémoire heap, file size, jobs/s). Sans ça, on agrège tout sur le main et on perd la visibilité par worker. Le naming convention type worker_lag_seconds{worker_id="3"} permet de plotter et alerter par instance. Pour le tracing distribué, propager le trace_id via le message au worker, et démarrer un span enfant dans le worker — OpenTelemetry supporte ce pattern.

En 2026, l'écosystème Node est mature sur ces sujets. piscina est le pool de référence ; tinypool est une alternative plus légère utilisée par Vitest. Pour les child_process, execa enveloppe l'API standard avec une ergonomie moderne (promises, kill-on-timeout, stdio en streams ou en string). Pour l'orchestration multi-process en local (dev), concurrently ou npm-run-all suffit ; en prod, k8s + livenessProbe/readinessProbe est l'état de l'art.

Bibliothèque tech perso — Achref