post

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

Fehlerbehandlung in Node.js ist keine try/catch-Kosmetik, sondern eine Entscheidung über Ebenen, Eskalation und geordnetes Beenden. Wer die vier Fehler-Ebenen kennt und Graceful Shutdown ernst nimmt, baut Dienste, die kontrolliert sterben statt korrupt weiterzulaufen.

Wenn ich in einer Schulung frage, wie die Teilnehmer Fehler in ihrem Node.js-Dienst behandeln, kommt fast immer dieselbe Antwort: "Wir haben überall try/catch." Kurz darauf folgt die zweite Frage, meist etwas leiser: "Aber warum stürzt der Prozess trotzdem manchmal ab – obwohl doch alles in try/catch steckt?"

Das ist der Moment, in dem ich das Whiteboard umdrehe. Denn die Vorstellung, Fehlerbehandlung sei eine Frage von genügend try/catch-Blöcken, führt in die Irre. try/catch fängt genau eine Sorte Fehler: synchrone Exceptions im gerade laufenden Stack. Node.js hat aber mindestens vier verschiedene Ebenen, auf denen Fehler entstehen – und jede davon hat ihre eigene Mechanik. Wer nur eine kennt, hat drei Löcher im Netz.

Fehlerbehandlung ist deshalb keine Detailentscheidung, die man am Ende noch "drüberlegt". Sie ist eine Architekturentscheidung. Sie bestimmt, ob ein Dienst unter Last kontrolliert stehen bleibt und sauber neu startet – oder ob er im halb korrupten Zustand weiterläuft und irgendwann Daten zerlegt, die niemand mehr zurückbekommt.

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:

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:

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:

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 fängt. Seit Node.js 15 – und damit auch in Node 18 LTS und Node 20 – ist der Default-Modus von --unhandled-rejections gleich throw. Eine unbehandelte Promise-Rejection ist keine bloße Warnung mehr, sondern beendet den Prozess. Das ist kein neues Verhalten, das man abwarten müsste; es ist der Ist-Zustand. Praktisch heißt das: Jede async-Kette braucht ein .catch oder ein try/catch – auch die, die man in einem Timer oder in einem Callback versteckt hat.

Die EventEmitter-Ebene ist die, die in Schulungen für die größten Aha-Momente sorgt. Ein Stream ohne 'error'-Listener bringt den Prozess zum Absturz, obwohl der Fehler völlig behandelbar gewesen wäre:

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 – 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.

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:

// 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 "normal operation" 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:

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:

// 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.

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.

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

Wenn ich am Ende einer Schulung auf dieses Thema zurückkomme, zeichne ich die Verantwortlichkeiten noch einmal als Kette: 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. 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.

Meine Faustregel für die Teilnehmer lautet am Ende: 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

Kommentare

Kommentar schreiben