Exécuter des tâches CPU-intensive sans dégrader les performances de l’Event Loop, c’est possible, et les Worker Threads sont l’outil fait pour ça. Après avoir distingué les opérations bloquantes des opérations non-bloquantes, il est temps de s’attaquer à un cas concret : que faire lorsqu’un traitement gourmand en calcul s’invite dans une application Node.js conçue autour d’un Event Loop ?
Le problème : un seul thread pour tout faire
Par défaut, le code Node.js s’exécute sur un unique thread, le main thread. C’est le cœur du modèle de concurrence basé sur l’Event Loop : une seule file d’exécution traite les événements les uns après les autres, en s’appuyant sur des entrées/sorties non-bloquantes pour rester réactive. Tant que le travail consiste à attendre (une requête réseau, une lecture disque, une réponse de base de données), ce modèle est redoutablement efficace.
Le problème surgit dès qu’une tâche CPU-intensive entre en scène. Comme elle s’exécute sur ce même thread unique, elle le monopolise : pendant tout le temps de son calcul, l’Event Loop ne peut plus rien traiter d’autre.
Imaginez un serveur web dont 99 % des endpoints effectuent des traitements non-bloquants, rapides et efficaces, qui répondent en moins de 200 ms. Il suffit d’un seul endpoint chargé d’un traitement lourd (redimensionnement d’images, transcodage vidéo, compression, calcul cryptographique) pour ralentir l’ensemble de l’application. Le temps que ce traitement s’exécute, toutes les autres requêtes patientent derrière, y compris celles qui auraient dû répondre instantanément.
C’est à la fois la beauté et la faiblesse de l’Event Loop : un modèle de concurrence élégant et performant, mais dont l’efficacité peut être mise à mal par une seule opération mal placée.
La solution : offloader avec les Worker Threads
Avant d’agir, encore faut-il diagnostiquer. Un endpoint qui dégrade l’ensemble du système se repère avec des outils d’observation de l’Event Loop comme Clinic.js ou le module natif perf_hooks, qui permet notamment de mesurer le delay de l’Event Loop. Une fois le coupable identifié, place à l’action.
Introduits de façon stable dans Node.js 12, les Worker Threads (node:worker_threads) offrent une forme de multithreading. Un Worker Thread est un thread OS à part entière : il dispose de son propre contexte d’exécution et de son propre Event Loop, ce qui lui permet de mener des opérations CPU-bound sans jamais bloquer le thread principal. Plusieurs threads peuvent être lancés en parallèle, communiquer entre eux via le passage de messages, et partager de la mémoire lorsque c’est pertinent.
L’idée est donc de sortir le calcul lourd du main thread et de le déléguer à un worker. Voici une mise en place minimale : le thread principal reste libre de traiter les autres requêtes pendant que le worker effectue le calcul.
// main.ts : reste sur le main thread, l'Event Loop n'est jamais bloqué
import { Worker } from "node:worker_threads";
function runHeavyTask(input: number): Promise<number> {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
workerData: input,
});
worker.once("message", resolve);
worker.once("error", reject);
worker.once("exit", (code) => {
if (code !== 0) {
reject(new Error(`Worker arrêté avec le code ${code}`));
}
});
});
}
// L'appel ne bloque pas : le calcul part dans un thread dédié.
const result = await runHeavyTask(42);
// worker.ts : s'exécute sur un thread OS séparé
import { parentPort, workerData } from "node:worker_threads";
function cpuIntensiveComputation(n: number): number {
// Traitement lourd : compression, hashing, redimensionnement d'image…
let acc = 0;
for (let i = 0; i < n * 1_000_000; i++) {
acc += Math.sqrt(i);
}
return acc;
}
const output = cpuIntensiveComputation(workerData);
parentPort?.postMessage(output);
Ce mécanisme est déjà au cœur de nombreuses bibliothèques de l’écosystème. Les test runners comme Vitest, Jest ou Playwright s’appuient sur les Worker Threads pour paralléliser l’exécution des tests, avec un impact drastique sur les performances. Si vous lancez vos suites de tests en parallèle aujourd’hui, vous bénéficiez déjà des Worker Threads sans le savoir.
Un Worker Thread, une ressource comme une autre à gérer
La facilité avec laquelle on instancie un new Worker() peut donner envie d’en créer à la volée, un par tâche. C’est précisément le piège à éviter. Les threads sont des ressources coûteuses : leur création et leur destruction ont un coût non négligeable, en temps comme en mémoire. Multiplier les threads éphémères, c’est passer son temps à payer ce coût de mise en place plutôt qu’à calculer.
C’est exactement le raisonnement du pattern Bulkhead : une ressource limitée et coûteuse doit être bornée et réutilisée, jamais gaspillée. Concrètement, il faut limiter le nombre de threads créés et maximiser leur réutilisation à l’aide d’une thread pool. On instancie un nombre fixe de workers une bonne fois pour toutes, typiquement calé sur le nombre de cœurs disponibles, puis on leur distribue les tâches au fil de l’eau.
Heureusement, inutile d’écrire cette logique de pooling soi-même. L’écosystème propose des solutions battle-tested comme Piscina.js, qui gère pour vous un pool de Worker Threads, voire de processus (node:child_process), avec mise en file d’attente des tâches, dimensionnement dynamique et réutilisation des threads.
// pool.ts : un pool de threads créé une seule fois
import Piscina from "piscina";
import { availableParallelism } from "node:os";
const pool = new Piscina({
filename: new URL("./worker.ts", import.meta.url).href,
// On borne le pool au nombre de cœurs disponibles
maxThreads: availableParallelism(),
});
// Chaque appel réutilise un thread du pool plutôt que d'en créer un nouveau.
// Les tâches excédentaires sont mises en file d'attente automatiquement.
async function handleRequest(payload: number): Promise<number> {
return pool.run(payload);
}
// Mille tâches, mais jamais plus de `maxThreads` threads vivants.
const results = await Promise.all(
Array.from({ length: 1000 }, (_, i) => handleRequest(i))
);
// worker.ts : Piscina injecte directement le payload en argument
export default function cpuIntensiveComputation(n: number): number {
let acc = 0;
for (let i = 0; i < n * 1_000_000; i++) {
acc += Math.sqrt(i);
}
return acc;
}
Avec cette approche, on conserve tout le bénéfice des Worker Threads, un Event Loop principal qui reste réactif, sans en payer le coût caché. Le pool absorbe les pics de charge en mettant les tâches en file, et le nombre de threads vivants reste maîtrisé quelle que soit la pression entrante.
▶ La démonstration en vidéo est disponible sur le post LinkedIn original.
Conclusion
L’Event Loop de Node.js est un excellent modèle de concurrence pour les charges orientées entrées/sorties, mais il reste vulnérable à une seule tâche CPU-intensive qui monopolise le thread principal. Les Worker Threads offrent la parade : sortir ce calcul du chemin critique pour le confier à un thread dédié, et laisser l’Event Loop continuer à servir les autres requêtes.
À une condition près, toutefois : traiter ces threads comme la ressource coûteuse qu’ils sont. Plutôt que d’en créer à la volée, on les borne et on les réutilise via une thread pool, manuellement ou en s’appuyant sur une solution éprouvée comme Piscina.js. C’est à ce prix que le multithreading devient un véritable levier de performance, et non une nouvelle source de gaspillage.