# child_process in Node.js: Skalierung ohne Illusionen

URL: https://www.mikebild.dev/de/blog/nodejs-child-process-und-skalierung/

Nicht-blockierend heißt nicht parallel. Node.js nimmt Tausende gleichzeitiger Netzwerkverbindungen an, ohne dabei ins Straucheln zu geraten – Rechenarbeit auf mehrere Kerne verteilt es deshalb noch lange nicht. Die beiden Eigenschaften werden ständig in einen Topf geworfen, und aus dieser Verwechslung entsteht der Endpoint, der unter Last die gesamte Anwendung einfriert, ohne dass jemand versteht, warum.

Der Unterschied liegt darin, woraus die Arbeit besteht: aus Warten oder aus Rechnen. Warten bekommt Node von allein in den Griff. Rechnen nicht. Dafür gibt es `child_process`, `cluster` und `worker_threads` – drei Werkzeuge mit ähnlichen Namen, sehr verschiedenen Kosten und einem gemeinsamen Missbrauchsmuster.

## Ein Event Loop, ein Thread, ein Nadelöhr

Fangen wir bei der Wurzel an. Ein Node.js-Prozess hat einen Event Loop, und JavaScript läuft in diesem Prozess auf genau einem Thread. Das ist kein Bug, das ist das Designprinzip. Solange die Arbeit aus Warten besteht – auf eine Datenbankantwort, auf eine Datei, auf einen HTTP-Response – kann der Event Loop in der Zwischenzeit andere Dinge erledigen. Die eigentliche Ein- und Ausgabe passiert im Hintergrund über libuv, und dein Code bekommt am Ende nur den fertigen Callback. Wer verstehen will, wie diese Maschinerie im Detail tickt, findet in meinem [Node.js-Training zur Event-Loop-Architektur](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/) die ausführliche Fassung.

Das Problem beginnt, sobald die Arbeit nicht aus Warten besteht, sondern aus Rechnen. Ein Bild skalieren, ein großes JSON parsen und transformieren, ein Passwort mit einer teuren Hash-Funktion prüfen, eine kryptografische Operation, eine synchrone Schleife über Millionen Einträge. Diese Arbeit findet auf demselben einen Thread statt. Und solange sie läuft, kann der Event Loop nichts anderes tun – keine neuen Requests annehmen, keine Timer feuern, keine Callbacks abarbeiten. Alles steht.

Der folgende Code sieht harmlos aus und rutscht in Code-Reviews trotzdem regelmäßig durch:

```js
const http = require('node:http');

function fibonacci(n) {
  if (n < 2) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

http
  .createServer((req, res) => {
    if (req.url === '/compute') {
      const result = fibonacci(45); // blocks the event loop for seconds
      res.end(`result: ${result}`);
    } else {
      res.end('ok');
    }
  })
  .listen(3000);
```

Solange `fibonacci(45)` rechnet, bekommt kein einziger anderer Client eine Antwort – auch nicht auf den harmlosen `/`-Pfad, der eigentlich in Mikrosekunden fertig wäre. Ein einziger langsamer Request legt den ganzen Server lahm. Genau das meint die offizielle Doku mit „Don't Block the Event Loop“.

## Vier Werkzeuge, vier Aufgaben

Node.js bietet für dieses Problem mehrere Werkzeuge an, und die werden regelmäßig verwechselt. Es lohnt sich, sie sauber auseinanderzuhalten:

- `child_process` startet einen komplett eigenen Betriebssystemprozess mit eigenem V8, eigenem Heap, eigenem Event Loop. Kommunikation läuft über Pipes bzw. – bei `fork` – über einen IPC-Kanal per Message-Passing. Kein geteilter Speicher.
- `cluster` startet mehrere Node-Instanzen hinter einem gemeinsamen Port und verteilt eingehende Verbindungen auf sie. Es baut intern auf `child_process.fork()` auf und dient dem Request-Durchsatz.
- `worker_threads` startet einen weiteren Thread innerhalb desselben Prozesses. Jeder Worker hat sein eigenes V8-Isolate und seinen eigenen Event Loop, kann aber optional echten Speicher mit dem Hauptthread teilen. Gedacht für CPU-Arbeit.
- Die normalen asynchronen APIs sind für alles, was I/O ist – und das braucht keine der drei oberen Optionen.

Diesen letzten Punkt betone ich bewusst, weil dort der häufigste Denkfehler lauert. Aber der Reihe nach.

## child_process: spawn, exec, execFile, fork

Das Modul `node:child_process` bietet vier Wege, einen Kindprozess zu starten, und die Unterschiede sind praxisrelevant.

`spawn(command, args, options)` startet ein Programm und gibt dir dessen `stdout` und `stderr` als Streams. Es puffert nichts und startet standardmäßig keine Shell. Das ist die richtige Wahl, wenn die Ausgabe groß sein kann oder du sie streamend verarbeiten willst.

`exec(command, options, callback)` startet eine echte Shell und puffert die gesamte Ausgabe im Speicher, bevor der Callback aufgerufen wird. Das ist bequem für kurze Kommandos, hat aber zwei Tücken: Der Default für `maxBuffer` liegt bei 1 MB – wird die Ausgabe größer, killt Node den Prozess mit `ERR_CHILD_PROCESS_STDIO_MAXBUFFER`. Und weil eine Shell im Spiel ist, ist `exec` mit ungeprüftem Input eine Einladung zur Shell-Injection.

`execFile(file, args, options, callback)` verhält sich wie `exec`, startet aber keine Shell. Das ist sicherer und effizienter, wenn du ohnehin nur ein konkretes Programm mit Argumenten aufrufst.

```js
const { execFile } = require('node:child_process');

execFile('git', ['rev-parse', 'HEAD'], (err, stdout) => {
  if (err) throw err;
  console.log('current commit:', stdout.trim());
});
```

Die Auswahl passt in drei Zeilen: kurze Ausgabe eines konkreten Programms – `execFile`. Große oder streamende Ausgabe – `spawn`. `exec` mit Shell nur, wenn du die Shell-Features (Pipes, Globbing) wirklich brauchst und den Input kontrollierst.

Der interessanteste der vier ist `fork`. Es ist ein Spezialfall von `spawn`, der gezielt eine neue Node-Instanz startet – über `process.execPath`, denselben Node-Binary – und automatisch einen IPC-Kanal einrichtet. Damit kannst du zwischen Eltern- und Kindprozess Nachrichten austauschen, ohne dich um Pipes und Serialisierung von Hand zu kümmern.

## Das Hauptbeispiel: schwere Berechnung in einem fork

Nehmen wir das blockierende Fibonacci-Beispiel von oben und lagern die Rechnung in einen Kindprozess aus. Der Elternprozess bleibt reaktionsfähig, weil die CPU-Last in einem völlig anderen OS-Prozess landet.

Zuerst der Worker in `worker.js`:

```js
// worker.js
function fibonacci(n) {
  if (n < 2) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

process.on('message', ({ n }) => {
  const result = fibonacci(n);
  process.send({ n, result });
});
```

Und der Elternprozess, der ihn startet und beauftragt:

```js
// main.js
const { fork } = require('node:child_process');
const http = require('node:http');

const child = fork('./worker.js');

const pending = new Map();
child.on('message', ({ n, result }) => {
  const res = pending.get(n);
  if (res) {
    res.end(`fibonacci(${n}) = ${result}`);
    pending.delete(n);
  }
});

http
  .createServer((req, res) => {
    if (req.url === '/compute') {
      pending.set(45, res);
      child.send({ n: 45 }); // heavy work happens elsewhere
    } else {
      res.end('ok'); // stays responsive during the computation
    }
  })
  .listen(3000);
```

Der entscheidende Punkt an dieser Stelle: Der `/`-Pfad antwortet jetzt sofort, auch während `/compute` noch rechnet. Der Event Loop des Hauptprozesses ist frei, weil die teure Schleife in einem separaten Prozess mit eigenem V8-Heap läuft. Die Kommunikation zwischen beiden geht ausschließlich über Nachrichten – hier per `child.send()` und dem `message`-Event.

Genau hier lohnt ein zweiter Blick: `send` ist kein Shared Memory. Die Nachricht wird serialisiert und im Kindprozess als Kopie neu aufgebaut. Der Default ist `serialization: 'json'`, und JSON verliert Typen – ein `Date` wird zum String, `undefined` verschwindet, `BigInt` funktioniert gar nicht. Wer strukturierte Werte über IPC schicken will, kann `serialization: 'advanced'` setzen, was den Structured-Clone-Algorithmus nutzt und deutlich mehr Typen erhält.

Der folgende Ablauf zeigt das Prinzip als Sequenz:

```mermaid
sequenceDiagram
    participant Client
    participant Main as Main Prozess<br/>(Event Loop frei)
    participant Child as Forked Prozess<br/>(eigener V8-Heap)
    Client->>Main: GET /compute
    Main->>Child: send({ n: 45 })
    Note over Main: Event Loop bleibt frei<br/>für andere Requests
    Child->>Child: heavy computation
    Child-->>Main: message({ result })
    Main-->>Client: HTTP 200 result
```

## worker_threads: dasselbe Ziel, geteilter Speicher

Ein `fork` ist robust, aber teuer. Jeder Kindprozess bringt ein vollständiges V8 und einen eigenen Heap mit – das kostet Speicher und Startzeit. Wenn du CPU-Arbeit parallelisieren willst und dabei nicht die volle Isolation eines eigenen Prozesses brauchst, sind `worker_threads` die leichtere Alternative. Sie sind seit Node 12 stabil und 2023 längst produktiver Standard.

Ein Worker läuft im selben Prozess, aber in einem eigenen Thread mit eigenem V8-Isolate und eigenem Event Loop. Dieselbe Rechnung sieht so aus:

```js
// task.js
const { parentPort, workerData } = require('node:worker_threads');

function fibonacci(n) {
  if (n < 2) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

parentPort.postMessage(fibonacci(workerData.n));
```

```js
// main-threads.js
const { Worker } = require('node:worker_threads');

function computeFib(n) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./task.js', { workerData: { n } });
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

computeFib(45).then((result) => {
  console.log('fibonacci(45) =', result);
});
```

Der große Unterschied zum `fork`: Worker-Threads können echten Speicher teilen. Ein `SharedArrayBuffer` ist für Haupt- und Worker-Thread derselbe Speicherbereich – keine Kopie, keine Serialisierung. Alternativ lässt sich ein `ArrayBuffer` per `transferList` übergeben, was ein Zero-Copy-Transfer ist: Der Buffer wandert zum Empfänger und ist beim Sender danach unbrauchbar. Für große Datenmengen zwischen Threads ist das ein echter Vorteil, den ein separater Prozess prinzipbedingt nicht bieten kann.

## Der Fallstrick, der am meisten Schaden anrichtet

Der Fehler, der in echten Codebasen die meiste Zeit verbrennt, ist dieser: I/O-bound Arbeit mit `worker_threads` oder `cluster` „beschleunigen“ zu wollen. Er ist mir in mehreren Projekten begegnet, jedes Mal mit derselben Begründung.

Die Motivation ist verständlich. Jemand hat gehört, dass Worker parallelisieren, sieht einen Endpoint, der viele Datenbankabfragen oder HTTP-Calls macht, und packt das Ganze in einen Worker-Pool. Das Ergebnis ist regelmäßig langsamer als vorher. Der Grund: Netzwerk- und Datei-I/O ist in Node.js bereits asynchron und blockiert den Event Loop überhaupt nicht. Da ist nichts zu entlasten. Die Doku sagt es unmissverständlich: Worker-Threads helfen bei I/O-intensiver Arbeit nicht nennenswert. Was du bekommst, ist der Overhead für Thread-Erstellung und Message-Passing – ohne jeden Gewinn.

Die Regel ist also einfach und trotzdem wird ständig gegen sie verstoßen:

- Ist die Arbeit I/O-bound – Datenbank, Netzwerk, Dateisystem? Dann bleib bei den normalen asynchronen APIs. Worker und Prozesse bringen hier nichts außer Komplexität.
- Ist die Arbeit CPU-bound – Hashing, Bildverarbeitung, Kompression, Krypto, große synchrone Transformationen? Erst dann sind `worker_threads` oder `child_process` das richtige Werkzeug.

Ein zweiter Fallstrick sitzt gleich daneben: pro Task einen Worker oder Prozess zu erzeugen. Die Erstellung ist teuer – V8 muss initialisiert werden, Speicher wird allokiert. Wer bei jedem Request einen frischen Worker startet, macht die Sache oft langsamer, nicht schneller. Die Antwort heißt Worker-Pool: eine feste Zahl wiederverwendbarer Worker, die Aufgaben aus einer Queue abarbeiten. Dieselbe Überlegung gilt für `fork` – halte die Kindprozesse am Leben und schick ihnen nacheinander Arbeit, statt sie ständig neu zu starten.

## cluster: Durchsatz, nicht Entlastung

Bleibt `cluster`, und auch hier gibt es ein hartnäckiges Missverständnis. `cluster` löst nicht das Problem der einzelnen blockierenden Berechnung. Es löst das Problem des Durchsatzes: Ein einzelner Node-Prozess nutzt nur einen Kern, aber deine Maschine hat mehrere. `cluster` startet pro Kern eine Node-Instanz, alle lauschen hinter demselben Port, und der Primary verteilt eingehende Verbindungen im Round-Robin.

```js
const cluster = require('node:cluster');
const http = require('node:http');
const os = require('node:os');

if (cluster.isPrimary) {
  for (let i = 0; i < os.cpus().length; i++) {
    cluster.fork();
  }
  cluster.on('exit', (worker) => {
    console.log(`worker ${worker.process.pid} died, restarting`);
    cluster.fork();
  });
} else {
  http
    .createServer((req, res) => res.end(`handled by pid ${process.pid}`))
    .listen(3000);
}
```

Damit verdoppelt oder vervierfacht sich der Durchsatz an gleichzeitigen Requests. Aber wenn ein einzelner Request eine sekundenlange synchrone Berechnung auslöst, blockiert diese immer noch den Worker, der sie bearbeitet. `cluster` macht aus vielen langsamen Requests nur dann viele parallele, wenn jeder auf einem anderen Worker landet – und die begrenzte Zahl an Workern ist irgendwann erschöpft. Für das eigentliche CPU-Problem pro Request brauchst du weiterhin `worker_threads` oder ausgelagerte Prozesse. Übrigens ist `cluster.isPrimary` seit Node 16 der bevorzugte Name; `isMaster` existiert noch, gilt aber als veraltet.

## Wann was: die Entscheidung auf einen Blick

Die Entscheidung lässt sich auf wenige Fragen eindampfen:

```mermaid
flowchart TD
    A[Arbeit einordnen] --> B{I/O-bound?}
    B -->|Ja| C[Normale async APIs<br/>keine Worker nötig]
    B -->|Nein, CPU-bound| D{Geteilter Speicher<br/>oder leichtgewichtig?}
    D -->|Ja| E[worker_threads<br/>SharedArrayBuffer möglich]
    D -->|Nein, Isolation<br/>oder externes Skript| F[child_process / fork<br/>eigener Prozess + IPC]
    A --> G{Nur mehr Durchsatz<br/>auf allen Kernen?}
    G -->|Ja| H[cluster<br/>mehrere Instanzen, ein Port]
```

Die drei Werkzeuge beantworten verschiedene Fragen – sie konkurrieren nicht um dieselbe:

- `child_process` / `fork` gibt dir einen eigenen Prozess mit eigenem Heap und Kommunikation über kopierte IPC-Nachrichten – maximale Isolation, ideal für externe Programme oder eigenständige Node-Skripte.
- `cluster` gibt dir mehrere Node-Instanzen hinter einem Port für mehr Request-Durchsatz – es skaliert Instanzen, nicht einzelne Berechnungen.
- `worker_threads` gibt dir Threads im selben Prozess mit optional geteiltem Speicher – die richtige Wahl, um CPU-Arbeit zu parallelisieren, ohne einen ganzen Prozess hochzufahren.

## Fazit

Die Illusion, gegen die dieser Beitrag anschreibt, ist die bequemste: dass Node.js CPU-Arbeit schon irgendwie von allein auf alle Kerne verteilt. Tut es nicht. Ein Prozess, ein Event Loop, ein JS-Thread – und jede synchrone Rechenlast blockiert alles andere. Wer das akzeptiert, trifft danach die richtigen Entscheidungen fast von selbst: I/O braucht keine Worker, sondern nur die asynchronen APIs, die Node ohnehin mitbringt. Erst CPU-Arbeit rechtfertigt schwereres Geschütz – `worker_threads` für Parallelität im Prozess mit geteiltem Speicher, `fork` für isolierte Prozesse, `cluster` für reinen Durchsatz.

Der teuerste Fehler ist nicht, das falsche Werkzeug zu wählen. Er ist, ein Werkzeug zu greifen, wo gar kein Problem ist – I/O mit Workern zu „beschleunigen“ und dafür Komplexität und Overhead einzukaufen, ohne eine einzige Millisekunde zu gewinnen. Miss erst, ob du überhaupt CPU-gebunden bist. Wenn du es bist, weißt du jetzt, wohin die Arbeit gehört. Wer ohnehin große Datenmengen durch seine Anwendung schiebt, findet in meinem Beitrag zu [Node.js-Streams und Backpressure](https://www.mikebild.dev/de/blog/nodejs-streams-und-backpressure/) die passende Ergänzung – denn oft ist die beste Skalierung die, bei der man Daten gar nicht erst komplett in den Speicher lädt.

## Weiterführende Quellen

- Repository mit den Codebeispielen: [github.com/mikebild/introduction-nodejs](https://github.com/mikebild/introduction-nodejs)
- [child_process – offizielle Node.js-Doku](https://nodejs.org/api/child_process.html)
- [worker_threads – offizielle Node.js-Doku](https://nodejs.org/api/worker_threads.html)
- [cluster – offizielle Node.js-Doku](https://nodejs.org/api/cluster.html)
- [Don't Block the Event Loop – Node.js Learn](https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop)
