# Fehler als Architektur in Node.js: Netze gegen den Absturz

URL: https://www.mikebild.dev/de/blog/nodejs-fehler-als-architektur/

Ein `try/catch` fängt synchrone Exceptions im gerade laufenden Stack und Rejections von Promises, die innerhalb desselben Blocks `await`et werden. Ein späterer Callback, eine losgelöste Promise und das `'error'`-Event eines EventEmitters erreichen diesen Block dagegen nicht. Ein `try/catch` um deren bloßen Startaufruf ist dann nur Dekoration.

Damit hat eine Node.js-Anwendung mindestens vier Ebenen, auf denen Fehler entstehen, und jede davon hat ihre eigene Mechanik. Wer nur die erste kennt, hat drei Löcher im Netz – und wundert sich, warum der Prozess trotzdem abstürzt, obwohl doch „überall try/catch“ steht.

Wo diese Netze hängen, entscheidet sich beim Entwurf und nicht in der Woche vor dem Go-live, wenn jemand noch schnell etwas Fehlerbehandlung darüberlegt. Daran hängt, ob ein Dienst unter Last stehen bleibt und in Sekunden frisch wieder da ist – oder ob er halb kaputt weiterarbeitet und Daten hinterlässt, die hinterher niemand mehr geradebiegt.

Dabei sind zwei Achsen zu trennen. Callback, Promise und EventEmitter beschreiben, **wie** ein Fehler den Code erreicht. Operational Errors und Programmer Errors beschreiben, **ob** der konkrete Zustand noch beherrschbar ist. Ein erwartbares Timeout kann am Request-Rand beantwortet oder wiederholt werden. Eine gebrochene Invariante im eigenen Code sollte bis zur Prozessgrenze eskalieren, damit der Dienst nach einem geordneten Shutdown frisch startet. Nicht jeder Fehler verlangt den Prozessabbruch; ein unbekannter Programmzustand darf aber nicht als normaler Betriebsfehler weiterlaufen.

## Die vier Ebenen, auf denen Fehler entstehen

Bevor wir über Netze reden, muss klar sein, welche Fehlerquellen es in einer Node.js-Anwendung überhaupt gibt. Es sind im Kern vier, und sie unterscheiden sich nicht im Schweregrad, sondern im Mechanismus, mit dem der Fehler zu dir kommt:

- Synchron: Ein Fehler wird direkt im aktuellen Aufrufstapel geworfen. Ihn fängt ein klassisches `try/catch`. Wer hier nichts fängt, bekommt die Exception eine Ebene höher gereicht – bis ganz nach oben.
- Error-first-Callbacks: Die alte, immer noch allgegenwärtige Node-Konvention. Der Fehler kommt nicht als Exception, sondern als erstes Argument der Callback-Funktion: `callback(err, result)`. Kein `throw`, kein `catch` – nur ein Wert, den man aktiv prüfen muss.
- Promises und async/await: Ein Fehler wird zur Rejection der Promise. Man fängt ihn mit `.catch()` oder mit `try/catch` um ein `await`. Wird er nirgends gefangen, entsteht eine `unhandledRejection`.
- EventEmitter: Server, Sockets, Streams. Der Fehler kommt als `'error'`-Event. Und hier lauert die böseste Falle: Fehlt der `'error'`-Listener, wird der Fehler nicht still verschluckt – er wird als Exception geworfen und reißt den Prozess mit.

Diese vier Ebenen zu trennen, ist der erste Schritt. Der zweite ist zu verstehen, dass darüber noch eine fünfte Schicht liegt: das letzte Netz, das greift, wenn auf keiner der vier Ebenen jemand zuständig war.

## Ebene für Ebene: wo der Fehler wirklich ankommt

Nehmen wir die Ebenen einzeln durch. Synchron ist einfach – das kennt jeder:

```js
function parseConfig(raw) {
  try {
    return JSON.parse(raw);
  } catch (err) {
    // synchronous exception, caught right here
    return { ok: false, reason: err.message };
  }
}
```

Der Denkfehler beginnt, sobald Asynchronität ins Spiel kommt. Ein `try/catch` um einen error-first-Callback ist wirkungslos, weil der Callback erst später und in einem anderen Stack aufgerufen wird:

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

// This try/catch catches NOTHING from the callback below.
try {
  fs.readFile('/etc/config.json', (err, data) => {
    if (err) return handle(err); // the error arrives HERE, as first argument
    process.stdout.write(data);
  });
} catch (err) {
  // never reached for the async error
}
```

Der Fehler steckt im `err`-Argument, nicht in einer geworfenen Exception. Vergisst man das `if (err) return ...`, läuft der Code mit `data === undefined` weiter – und der eigentliche Absturz passiert dann später an einer Stelle, die mit der Ursache nichts zu tun hat. Das ist die häufigste Art, wie sich Fehler in Node.js verstecken.

Promises drehen das wieder zurück in Richtung `try/catch` – aber nur um `await` herum:

```js
async function loadUser(id) {
  try {
    const user = await db.users.findById(id);
    if (!user) throw new Error(`user ${id} not found`);
    return user;
  } catch (err) {
    // rejection AND thrown error both land here
    logger.warn({ err, id }, 'loadUser failed');
    throw err; // re-throw: let the caller decide
  }
}
```

Wichtig ist die Konsequenz, wenn niemand die Rejection beobachtet. Seit Node.js 15 ist der Default-Modus von `--unhandled-rejections` gleich `throw`. Eine unbehandelte Promise-Rejection ist keine bloße Warnung mehr, sondern beendet den Prozess. Praktisch heißt das: Jede asynchrone Kette muss entweder zu einem zuständigen Aufrufer zurückgegeben oder spätestens an einer klaren Grenze mit `.catch` beziehungsweise `try/catch` behandelt werden. Losgelöste Promises in Timern oder Callbacks brauchen diese Grenze ausdrücklich.

Die EventEmitter-Ebene ist die am meisten unterschätzte. Ein Stream ohne `'error'`-Listener bringt den Prozess zum Absturz, obwohl der Fehler völlig behandelbar gewesen wäre:

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

const stream = fs.createReadStream('/does/not/exist');

// WITHOUT this line, the emitted 'error' is thrown and crashes the process.
stream.on('error', (err) => {
  logger.error({ err }, 'stream failed, handled gracefully');
});

stream.on('data', (chunk) => process.stdout.write(chunk));
```

Das ist Standard-Semantik von Node: Ein EventEmitter, der ein `'error'`-Event emittiert, ohne dass ein Listener registriert ist, wirft den Fehler. Deshalb ist der `'error'`-Handler bei Servern, Sockets und Streams nicht optional, sondern architektonisch Pflicht. Wer viel mit Prozessgrenzen und Streams arbeitet, kennt das Muster aus dem Umgang mit [child_process und Skalierung](https://www.mikebild.dev/de/blog/nodejs-child-process-und-skalierung/) – auch dort ist das `'error'`-Event auf dem Kind-Prozess der Unterschied zwischen „sauber behandelt“ und „harter Absturz“.

## Das letzte Netz – und wofür es nicht da ist

Was passiert, wenn auf keiner der vier Ebenen jemand zuständig war? Dann greift das letzte Netz: `uncaughtException` und `unhandledRejection`.

```mermaid
flowchart TD
  E["Fehler entsteht"] --> D{"Welche Ebene<br/>fängt ihn?"}
  D -->|"try/catch"| H["behandeln &<br/>weitermachen"]
  D -->|"error-first callback"| H
  D -->|"promise .catch"| H
  D -->|"emitter 'error'"| H
  D -->|"niemand zuständig"| ESC["eskaliert zum<br/>letzten Netz"]
  ESC --> LOG["loggen<br/>uncaughtException /<br/>unhandledRejection"]
  LOG --> GS["Graceful Shutdown<br/>starten"]
  GS --> CLOSE["server.close()"]
  CLOSE --> EXIT["process.exit(1)"]
  EXIT --> SUP["Supervisor<br/>pm2 / systemd / K8s<br/>startet frisch"]
```

Hier liegt der entscheidende und oft missverstandene Punkt. Registriert man einen `uncaughtException`-Handler, überschreibt man damit das Default-Verhalten von Node. Ohne Handler schreibt Node den Stacktrace nach stderr und beendet den Prozess mit Exit-Code 1. Sobald ein Handler existiert, passiert dieser automatische Exit nicht mehr. Die Node-Dokumentation ist an dieser Stelle explizit: „Adding a handler overrides this default behavior.“

Das bedeutet: Wenn du einen `uncaughtException`-Handler registrierst, musst du selbst dafür sorgen, dass der Prozess beendet wird. Tust du das nicht, läuft er in einem undefinierten Zustand weiter. Und genau das ist das Anti-Pattern, das ich in echten Codebasen am häufigsten sehe:

```js
// ANTI-PATTERN: swallow and keep running.
process.on('uncaughtException', (err) => {
  logger.error({ err }, 'oops'); // logged, but then... nothing
  // no process.exit — the process stays alive in a corrupt state
});
```

Nach einer uncaught Exception ist der Zustand der Anwendung potenziell korrupt: halb abgeschlossene Transaktionen, geleakte File-Handles, ein Connection-Pool in unklarem Zustand. Die Node-Doku warnt ausdrücklich davor, danach den Normalbetrieb wieder aufzunehmen. `uncaughtException` und `unhandledRejection` sind ein letztes Netz zum sauberen Beenden – nicht zum Weitermachen.

Richtig sieht das Netz so aus: loggen, dann kontrolliert beenden. Für die Rejection eskaliere ich bewusst zur Exception, damit beide Wege im selben Shutdown münden:

```js
process.on('unhandledRejection', (reason) => {
  logger.error({ reason }, 'unhandled rejection, escalating');
  // escalate to uncaughtException so we exit through one path
  throw reason;
});

process.on('uncaughtException', (err, origin) => {
  logger.fatal({ err, origin }, 'uncaught exception, shutting down');
  process.exit(1);
});
```

Es gibt noch eine leisere Variante, die man kennen sollte: `uncaughtExceptionMonitor`. Dieser Handler dient nur zum Beobachten und Loggen – er ändert das Crash-Verhalten nicht. Ist kein `uncaughtException`-Handler registriert, crasht der Prozess weiterhin, aber der Monitor hat vorher noch geloggt. Das ist ideal, wenn man den Absturz protokollieren will, ohne ihn versehentlich zu unterdrücken:

```js
// Log the crash without changing the crash behavior.
process.on('uncaughtExceptionMonitor', (err, origin) => {
  logger.fatal({ err, origin }, 'about to crash');
});
```

## Graceful Shutdown: kontrolliert sterben, nicht reparieren

Der Absturz über das letzte Netz ist die harte Variante. Der Normalfall im Betrieb ist ein anderer: Der Supervisor – pm2, systemd oder ein Kubernetes-Orchestrator – will den Prozess beenden, etwa bei einem Deployment oder beim Herunterskalieren. Dazu schickt er ein `SIGTERM`. Und hier ist die Anwendung dafür verantwortlich, laufende Arbeit sauber abzuschließen, nicht für Selbstreparatur.

```mermaid
sequenceDiagram
  participant O as Orchestrator
  participant A as App
  O->>A: SIGTERM
  A->>A: stop accepting new requests
  A->>A: drain in-flight requests
  A->>A: close DB / pools
  A-->>O: exit 0
  O->>O: restart fresh
```

Der Ablauf ist immer derselbe: `SIGTERM` abfangen, keine neuen Verbindungen mehr annehmen, laufende Anfragen zu Ende bringen, Ressourcen schließen, dann beenden. Der Supervisor startet danach einen frischen Prozess. Wichtig ist der Default, den man dabei überschreibt: Ohne eigenen Listener beendet Node bei `SIGTERM` mit Exit-Code `128 + 15 = 143`. Sobald man einen Listener registriert, ist man selbst für das Beenden zuständig.

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

const server = http.createServer(handler);
server.listen(3000);

let isShuttingDown = false;

function shutdown(signal) {
  if (isShuttingDown) return; // guard against double invocation
  isShuttingDown = true;
  logger.info({ signal }, 'graceful shutdown started');

  // stop accepting new connections, finish in-flight ones
  server.close((err) => {
    if (err) {
      logger.error({ err }, 'error during server.close');
      return process.exit(1);
    }
    logger.info('all connections drained, exiting');
    process.exit(0);
  });

  // fallback: never hang forever if a connection refuses to close
  setTimeout(() => {
    logger.error('shutdown timed out, forcing exit');
    process.exit(1);
  }, 10_000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
```

Zwei Details sind hier entscheidend. Erstens das `isShuttingDown`-Flag: Ein Container kann `SIGTERM` und kurz darauf noch ein Signal schicken; ohne Guard läuft der Shutdown doppelt und `server.close` wird auf einem bereits schließenden Server aufgerufen. Zweitens der Timeout mit `.unref()`. Wenn eine hängende Verbindung dafür sorgt, dass `server.close` nie zurückkommt, bleibt der Prozess sonst ewig stehen – bis der Orchestrator irgendwann hart mit `SIGKILL` nachhilft. Der Fallback-Timeout sorgt dafür, dass wir vorher selbst kontrolliert aussteigen. Das `.unref()` verhindert dabei, dass der Timer selbst den Prozess am Leben hält, falls der saubere Weg doch rechtzeitig fertig wird.

Diese Aufgabenteilung ist der Kern der ganzen Architektur: Die Anwendung ist dafür zuständig, ihre laufende Arbeit sauber zu Ende zu bringen und dann zu sterben. Für das Wiederanlaufen ist der Supervisor zuständig – nicht die Anwendung selbst. Ein Prozess, der versucht, sich nach einem schweren Fehler selbst zu heilen und weiterzulaufen, macht die Sache schlimmer. Ein Prozess, der kontrolliert stirbt und frisch neu gestartet wird, ist in Sekunden wieder in einem definierten Zustand.

## Warum das eine Architekturentscheidung ist

Die Verantwortlichkeiten hängen als Kette zusammen: Der Fehler entsteht auf einer der vier Ebenen. Idealerweise fängt ihn genau dort ein zuständiger Handler – im `try/catch`, in der `if (err)`-Prüfung, im `.catch`, im `'error'`-Listener. Erst wenn niemand zuständig war, eskaliert er zum letzten Netz, und das letzte Netz hat genau eine Aufgabe: sauber loggen und geordnet beenden.

Das ist keine Sammlung von Einzeltricks, sondern eine durchgehende Entscheidung darüber, wo ein Fehler behandelt wird und wo er nur noch zum kontrollierten Ende führt. Man entscheidet einmal, wie das Netz gespannt ist, und dann hält es überall – oder man entscheidet es nicht, und dann reißt es an der Stelle, an der man nie hingeschaut hat.

Wer das Zusammenspiel von Event Loop, Callbacks und Promises grundsätzlich vertiefen will, findet den Unterbau dazu im [Node.js-Training zur Event-Loop-Architektur](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/). Denn erst wenn klar ist, warum ein Callback in einem anderen Stack läuft als der `try/catch`, der ihn umgibt, wird verständlich, warum Fehlerbehandlung in Node.js eben nicht mit „überall try/catch“ erledigt ist.

Behandle jeden Fehler so nah wie möglich an der Ebene, auf der er entsteht. Spann das letzte Netz nur zum sauberen Sterben, nie zum Weiterleben. Und überlass das Wiederauferstehen dem Supervisor. Ein Dienst, der weiß, wie er stirbt, ist stabiler als einer, der um jeden Preis am Leben bleiben will.

## Weiterführende Quellen

- Repository mit Beispielen: [MikeBild/introduction-nodejs](https://github.com/MikeBild/introduction-nodejs)
- [process – Event: 'uncaughtException'](https://nodejs.org/api/process.html#event-uncaughtexception)
- [process – Event: 'unhandledRejection'](https://nodejs.org/api/process.html#event-unhandledrejection)
- [process – Signal-Events](https://nodejs.org/api/process.html#signal-events)
- [events – 'error'-Events des EventEmitter](https://nodejs.org/api/events.html#error-events)
- [CLI – --unhandled-rejections=mode](https://nodejs.org/api/cli.html#--unhandled-rejectionsmode)
- [Node.js Process API](https://nodejs.org/api/process.html)
