Worker Threads in Node.js: CPU-Arbeit auslagern
Wie du mit Worker Threads CPU-intensive Aufgaben verarbeitest, ohne den Event Loop zu blockieren – mit Thread-Pooling und typsicherem Messaging.

Node.js hat einen Single-Thread-Event-Loop, der sich hervorragend für I/O-lastige Arbeit eignet und miserabel für CPU-intensive Arbeit. Sobald du in einem Request-Handler mit Bildverarbeitung, kryptografischem Hashing oder PDF-Generierung beginnst, wartet jede andere Anfrage. Worker Threads lösen genau dieses Problem — und sie sind zugänglicher, als die meisten glauben.
Das Problem: den Event Loop blockieren
Der Event Loop kann immer nur eine Sache gleichzeitig erledigen. Eine CPU-gebundene Operation von 200 ms verlangsamt nicht nur eine Anfrage – sie verzögert jede Anfrage, die dahinter in der Warteschlange steht. Bei geringem Traffic fällt das nicht auf. Unter Last ist es katastrophal.
// ❌ Runs on the main thread — blocks everything while computing
export async function POST(req: Request) {
const { data } = await req.json();
const result = heavyComputation(data); // 500ms of pure CPU work
return Response.json({ result });
}
// ✅ Offloads to a worker thread — main thread stays responsive
export async function POST(req: Request) {
const { data } = await req.json();
const result = await pool.run({ type: "COMPUTE", payload: data });
return Response.json({ result });
}Die Lösung besteht nicht darin, setImmediate zu verwenden oder die Arbeit in Microtask-Häppchen aufzuteilen (auch wenn das durchaus seine Berechtigung hat). Für wirklich CPU-intensive Arbeit lautet die Antwort: Worker Threads.
Einen einfachen Worker einrichten
Node.js stellt Worker über das Modul worker_threads bereit. Ein Worker läuft in einer eigenen V8-Instanz mit einem eigenen Event Loop. Die Kommunikation erfolgt über Message Passing — standardmäßig gibt es keinen gemeinsamen Speicher.
// workers/compute.worker.ts
import { parentPort, workerData } from "worker_threads";
if (!parentPort) throw new Error("Must be run as a worker");
function heavyComputation(input: number[]): number {
// Simulate expensive work — sorting, hashing, parsing large datasets, etc.
return input.reduce((acc, val) => acc + Math.sqrt(val * Math.PI), 0);
}
const result = heavyComputation(workerData.input);
parentPort.postMessage({ result });// lib/run-in-worker.ts
import { Worker } from "worker_threads";
import path from "path";
export function runComputeWorker(input: number[]): Promise<number> {
return new Promise((resolve, reject) => {
const worker = new Worker(
path.resolve(__dirname, "../workers/compute.worker.js"),
{ workerData: { input } }
);
worker.on("message", ({ result }) => resolve(result));
worker.on("error", reject);
worker.on("exit", (code) => {
if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
});
});
}Für jede Anfrage einen eigenen Worker zu starten funktioniert beim Prototyping, bringt aber echten Overhead mit sich — allein der Thread-Start kostet 50–100 ms. Das richtige Muster für den Produktionsbetrieb ist ein Pool.
Einen Thread-Pool aufbauen
Threads für jede einzelne Aufgabe zu erstellen und wieder zu zerstören verschwendet die Startkosten und erzeugt Latenzspitzen. Ein Pool hält die Threads am Leben und nutzt sie über mehrere Anfragen hinweg wieder.
// lib/worker-pool.ts
import { Worker } from "worker_threads";
import path from "path";
interface PoolTask<T> {
data: unknown;
resolve: (value: T) => void;
reject: (error: Error) => void;
}
export class WorkerPool<T = unknown> {
private idle: Worker[] = [];
private queue: PoolTask<T>[] = [];
private currentTask = new Map<Worker, PoolTask<T>>();
constructor(workerPath: string, size: number) {
for (let i = 0; i < size; i++) {
const worker = new Worker(path.resolve(workerPath));
this.idle.push(worker);
worker.on("message", (result: T) => {
const task = this.currentTask.get(worker);
if (task) {
this.currentTask.delete(worker);
task.resolve(result);
}
this.idle.push(worker);
this.drain();
});
worker.on("error", (err) => {
const task = this.currentTask.get(worker);
if (task) task.reject(err);
this.idle.push(worker);
this.drain();
});
}
}
run(data: unknown): Promise<T> {
return new Promise((resolve, reject) => {
this.queue.push({ data, resolve, reject });
this.drain();
});
}
private drain() {
if (this.queue.length === 0 || this.idle.length === 0) return;
const worker = this.idle.pop()!;
const task = this.queue.shift()!;
this.currentTask.set(worker, task);
worker.postMessage(task.data);
}
}Die Poolgröße sollte der Anzahl deiner CPU-Kerne minus eins entsprechen — lass einen Kern für den Hauptthread und die I/O-Verarbeitung übrig. os.cpus().length - 1 ist für die meisten Server-Workloads ein zuverlässiger Standardwert.
Typsichere Message-Contracts
Worker kommunizieren über postMessage, das von Natur aus vom Typ any ist. Ein typisierter Message-Contract erkennt Inkonsistenzen bereits zur Kompilierzeit statt erst zur Laufzeit in Produktion.
// types/worker-messages.ts
export type WorkerRequest =
| { type: "HASH"; payload: { data: string; algorithm: "sha256" | "sha512" } }
| { type: "COMPRESS"; payload: { buffer: ArrayBuffer; level: number } }
| { type: "PARSE_CSV"; payload: { csv: string; delimiter: string } };
export type WorkerResponse =
| { type: "HASH"; result: string }
| { type: "COMPRESS"; result: ArrayBuffer }
| { type: "PARSE_CSV"; result: Record<string, string>[] };
// workers/multipurpose.worker.ts
import { parentPort } from "worker_threads";
import type { WorkerRequest, WorkerResponse } from "../types/worker-messages";
parentPort?.on("message", (req: WorkerRequest) => {
let response: WorkerResponse;
switch (req.type) {
case "HASH":
response = { type: "HASH", result: hashData(req.payload) };
break;
case "COMPRESS":
response = { type: "COMPRESS", result: compress(req.payload) };
break;
case "PARSE_CSV":
response = { type: "PARSE_CSV", result: parseCsv(req.payload) };
break;
}
parentPort?.postMessage(response);
});TypeScripts erschöpfendes switch erzeugt einen Kompilierfehler, wenn du einen neuen Nachrichtentyp hinzufügst, ohne ihn im Worker zu behandeln. Genau diese Sicherheit willst du über asynchrone Thread-Grenzen hinweg haben.
Übergib ArrayBuffer-Werte als Transferable-Objekte mit dem zweiten
Argument von postMessage(data, [buffer]). Dadurch wird der Besitz übertragen
statt geklont — die Kopierkosten sind unabhängig von der Payload-Größe null.
Wann man Worker Threads nicht einsetzen sollte
Worker Threads sind nicht kostenlos. Das Structured Cloning bei postMessage ist O(n) in Bezug auf die Payload-Größe. Verwende für große Buffer SharedArrayBuffer oder eine übertragbare Eigentümerschaft. Bei kleinen Payloads kann der Overhead den Nutzen komplett übersteigen.
| Anwendungsfall | Worker Threads? | Grund |
|---|---|---|
| Bild-/Videoverarbeitung | Ja | CPU-intensiv, Buffer sind übertragbar |
| Kryptografisches Hashing | Ja | CPU-gebunden, kleine Payloads |
| ML-Inferenz (ONNX, WASM) | Ja | Lange Laufzeit, CPU- oder WASM-intensiv |
| Datenbankabfragen | Nein | I/O-gebunden, async Treiber übernehmen das |
| Externe API-Aufrufe | Nein | I/O-gebunden, keine CPU-Arbeit beteiligt |
| JSON-Parsing (< 1 MB) | Nein | Overhead übersteigt den Nutzen |
| JSON-Parsing (> 10 MB) | Ja | CPU-Kosten dominieren in großem Maßstab |
Die praktische Schwelle: Wenn perf_hooks zeigt, dass dein Handler mehr als 10 ms mit synchroner CPU-Arbeit verbringt, ist das ein Kandidat für einen Worker Thread.
Alles zusammenführen in einer echten Route
// app/api/image/route.ts
import { WorkerPool } from "@/lib/worker-pool";
import type { WorkerResponse } from "@/types/worker-messages";
import os from "os";
// Initialize once at module load — not per request
const pool = new WorkerPool<WorkerResponse>(
require.resolve("@/workers/image-processor.worker"),
Math.max(1, os.cpus().length - 1)
);
export async function POST(req: Request) {
const formData = await req.formData();
const file = formData.get("image") as File | null;
if (!file) {
return Response.json({ error: "No file provided" }, { status: 400 });
}
const buffer = await file.arrayBuffer();
try {
const response = await pool.run({
type: "COMPRESS",
payload: { buffer, level: 8 },
}) as Extract<WorkerResponse, { type: "COMPRESS" }>;
return new Response(response.result, {
headers: { "Content-Type": "image/webp" },
});
} catch (err) {
console.error("[image-worker] processing failed:", err);
return Response.json({ error: "Processing failed" }, { status: 500 });
}
}Der Pool wird einmalig bei der Modulinitialisierung erstellt und für jede Anfrage wiederverwendet. Die Worker bleiben warm. Der Event Loop bleibt frei. Überzählige Anfragen reihen sich automatisch in eine Warteschlange ein und werden abgearbeitet, sobald Threads verfügbar werden.
Die wichtigsten Erkenntnisse
- CPU-intensive Arbeit blockiert jede Anfrage — eine einzige synchrone 200-ms-Operation erzeugt unter Last Tail-Latency-Spitzen bei allen gleichzeitigen Anfragen
- Starte einen Pool, keine einzelnen Worker — Worker pro Anfrage zu erstellen widerspricht dem eigentlichen Zweck; nutze Threads wieder und zahle die Startkosten nur einmal
- Übertrage große Buffer, klone sie nicht — übergib
ArrayBufferalsTransferable, um die Serialisierungskosten bei jeder Nachricht zu vermeiden - Typisiere deine Message-Contracts —
postMessageist standardmäßig vom Typany; Discriminated Unions bieten Sicherheit zur Kompilierzeit über Thread-Grenzen hinweg - Dimensioniere den Pool nach deinen Kernen —
os.cpus().length - 1ist der richtige Standardwert; lass Raum für den Event Loop und I/O - Profile, bevor du optimierst — Worker Threads bringen echte Komplexität mit sich; greife nur zu ihnen, wenn du die Blockierung des Event Loop mit
perf_hooksoder clinic.js messen kannst


