# Async-Kontext in Node.js: AsyncLocalStorage, async_hooks und diagnostics_channel

URL: https://www.mikebild.dev/de/blog/nodejs-async-context-mit-asynclocalstorage/

`AsyncLocalStorage` ist keine globale Variable mit einem besseren Namen. Jeder `run`-Aufruf hält seinen eigenen Store, und zwei nebenläufige Requests kommen sich darin nicht in die Quere – genau an dieser Stelle scheitert die globale Variable, zu der man sonst reflexhaft greift. Die Verwechslung hält sich trotzdem, weil beides von außen gleich aussieht: oben etwas hinlegen, weit unten wieder herausholen.

Wozu das taugt, zeigt sich im Betrieb und nicht im Code. Ob eine Ursachensuche Minuten oder Stunden dauert, hängt daran, ob sich ein einzelner Request lückenlos durch Logs, Services und Traces verfolgen lässt. Node.js bringt dafür drei Bausteine mit: `AsyncLocalStorage`, das darunterliegende `async_hooks` und `diagnostics_channel`. Im Beitrag über [Node.js debuggen und beobachten](https://www.mikebild.dev/de/blog/nodejs-debuggen-und-beobachten/) hatte ich mir Async-Kontext für später aufgehoben; das hole ich hier nach.

Das ist kein Randproblem. Sobald eine Anwendung mehr als ein paar Endpunkte hat, will man wissen, welche Logzeile zu welchem Request gehört. Man will den eingeloggten User im Fehlerbericht sehen. Man will einen Trace über mehrere Services hinweg zusammenhalten. Und all diese Werte gehören nun einmal zum Request, nicht zur Funktion, die gerade zufällig ausgeführt wird.

## Das Problem: Kontext durch die Tiefe fädeln

Schauen wir uns an, wie das ohne Hilfsmittel aussieht. Ich habe einen Handler, der einen Service ruft, der ein Repository ruft, das am Ende etwas loggen will. Die Request-ID entsteht ganz oben – gebraucht wird sie ganz unten.

```js
function handler(req, res) {
  const requestId = randomUUID();
  serviceA(req.body, requestId);
}

function serviceA(input, requestId) {
  // serviceA doesn't need requestId itself
  return repository(input, requestId);
}

function repository(input, requestId) {
  // repository doesn't either
  log('writing record', requestId);
}

function log(message, requestId) {
  console.log(`[${requestId}] ${message}`);
}
```

Das funktioniert – aber es ist unangenehm. Jede Funktion dazwischen bekommt einen Parameter aufgezwungen, den sie selbst nie benutzt. Sie reicht ihn nur durch. Kommt morgen ein User-Objekt dazu und übermorgen eine Trace-ID, wächst jede Signatur mit. Und die eine Bibliotheksfunktion, die man nicht ändern kann, reicht den Parameter natürlich nicht durch – dort reißt die Kette.

Man kann sich mit Umwegen behelfen: den Kontext an eine Klasseninstanz hängen, ein Objekt durchreichen, das alle Zusatzdaten sammelt, oder gar eine globale Variable setzen. Der Klasseninstanz-Weg zwingt einen in ein Objektmodell, das man vielleicht gar nicht will. Das Kontextobjekt ist nur eine hübscher verpackte Variante desselben Durchreichens. Und die globale Variable ist bei nebenläufigen Requests schlicht falsch – der nächste Request überschreibt sie, während der erste noch läuft. Keine dieser Varianten trägt wirklich.

Genau für dieses „context threading“-Problem gibt es in Node.js eine saubere Antwort.

## AsyncLocalStorage: der praktische Weg

`AsyncLocalStorage` steckt im Modul `node:async_hooks` und ist seit Node v16.4.0 stabil. Die Idee ist einfach: Man führt einen Callback in einem sogenannten Store aus, und dieser Store bleibt für alle asynchronen Operationen erhalten, die innerhalb dieses Callbacks entstehen. Beliebig tief unten liest man ihn wieder aus – ohne einen einzigen zusätzlichen Parameter.

Vorweg ein ehrlicher Hinweis: Für diesen Beitrag gibt es kein Beispiel aus einem konkreten Projektbestand. Die folgenden Codeblöcke sind generische Node.js-Beispiele, die ich gegen die offizielle Dokumentation geprüft habe.

```js
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

const als = new AsyncLocalStorage();

function handler(req, res) {
  const store = { requestId: randomUUID(), user: req.user };
  als.run(store, () => {
    serviceA(req.body);
  });
}

function serviceA(input) {
  return repository(input);
}

function repository(input) {
  log('writing record');
}

function log(message) {
  const { requestId } = als.getStore() ?? {};
  console.log(`[${requestId}] ${message}`);
}
```

Der Unterschied ist deutlich. `serviceA` und `repository` wissen nichts mehr von der Request-ID. Sie kennen ihren eigentlichen Job und sonst nichts. Nur ganz oben wird der Store gesetzt – mit `als.run(store, callback)` – und nur dort, wo er wirklich gebraucht wird, wird er mit `als.getStore()` gelesen.

Zwei Details sind wichtig. Erstens ist `run` synchron: Der Rückgabewert des Callbacks wird durchgereicht, und wenn der Callback wirft, wird der Fehler ebenfalls weitergegeben und der Kontext wieder verlassen. Zweitens liefert `getStore()` außerhalb eines `run`-Aufrufs schlicht `undefined`. Deshalb steht im Beispiel das `?? {}` – man sollte nie davon ausgehen, dass immer ein Store vorhanden ist.

Als Store eignet sich jedes beliebige Objekt. In der Praxis nehme ich gern eine `Map`, wenn im Verlauf eines Requests noch Werte dazukommen sollen, oder ein schlichtes Objekt, wenn der Inhalt von Anfang an feststeht. Wichtig ist nur, dass jeder `run`-Aufruf seinen eigenen Store bekommt – dann sind nebenläufige Requests sauber voneinander getrennt, ohne dass man sich um die Trennung selbst kümmern muss. Genau das ist der Gewinn gegenüber der globalen Variable von eben.

Diese Ebene zeigt das folgende Diagramm. Der Store wird oben gesetzt, die tiefen Aufrufe lesen ihn, ohne dass ein Parameter je weitergereicht wird.

```mermaid
flowchart TD
  Req["HTTP Request"] --> Run["als.run(store)"]
  Run --> A["serviceA()"]
  A --> R["repository()"]
  R --> L["log()"]
  Store[("Store<br/>requestId, user")]
  Run -.-> Store
  L -.->|"reads store"| Store
```

### enterWith – die riskante Abkürzung

Neben `run` gibt es `enterWith(store)`. Der Reiz ist offensichtlich: Man muss den restlichen Code nicht in einen Callback einrücken, sondern setzt den Kontext einfach und läuft danach normal weiter. Genau das ist aber auch die Gefahr. `enterWith` gilt für den gesamten Rest der synchronen Ausführung plus alle folgenden asynchronen Aufrufe – es gibt keine natürliche Klammer, die den Kontext wieder schließt. Setzt man das in einem Event-Handler, geraten unter Umständen nachfolgende Handler in denselben Kontext.

`enterWith` ist als experimentell markiert, und die Dokumentation ist ungewöhnlich direkt: Man solle `run()` benutzen, sofern es keine starken Gründe dagegen gebe. Praktisch heißt das: mit `run` anfangen. `enterWith` bleibt seltenen Sonderfällen vorbehalten und ist nicht der Normalweg.

## async_hooks: das Fundament darunter

Woher weiß `AsyncLocalStorage` überhaupt, welche asynchronen Operationen zu welchem `run`-Aufruf gehören? Darunter liegt `async_hooks`, die eigentliche Low-Level-Schicht. Sie stellt Lebenszyklus-Hooks für asynchrone Ressourcen bereit: Für jede solche Ressource durchläuft Node die Phasen `init`, `before`, `after` und `destroy`. Man kann sich in diese Phasen einklinken.

```js
import { createHook, executionAsyncId } from 'node:async_hooks';
import { writeSync } from 'node:fs';

const hook = createHook({
  init(asyncId, type, triggerAsyncId) {
    writeSync(1, `init ${type} id=${asyncId} trigger=${triggerAsyncId}\n`);
  },
  destroy(asyncId) {
    writeSync(1, `destroy id=${asyncId}\n`);
  },
});

hook.enable();
```

Warum `writeSync` statt `console.log`? Weil `console.log` selbst asynchrone Ressourcen erzeugt und man sich in einem `init`-Hook damit in eine Endlosschleife schreiben würde. Das allein zeigt schon, wie heikel diese Schicht im direkten Umgang ist.

Und hier muss ich deutlich werden: `async_hooks` ist experimentell, und die Node-Dokumentation rät ausdrücklich davon ab, es direkt einzusetzen – wegen „usability issues, safety risks, and performance implications“. Der `destroy`-Hook ist besonders teuer, weil er ein Tracking von Promises über den Garbage Collector aktiviert. Das kostet messbar Leistung.

Die Botschaft ist also nicht „lern async_hooks und benutz es“. Die Botschaft ist: `AsyncLocalStorage` steht auf diesem Fundament, aber es ist die stabile, empfohlene und praktische API. Man greift in aller Regel nicht selbst zu `createHook`. Ich zeige es hier nur, damit klar wird, was eine Ebene tiefer passiert – nicht als Empfehlung für den Produktionscode.

## diagnostics_channel: entkoppelte Instrumentierung

Der dritte Baustein löst ein anderes Problem. Angenommen, eine Bibliothek möchte melden, dass sie gerade einen Request startet – aber sie soll nicht vorschreiben, wer das mitbekommt oder was damit geschieht. Ein Logger? Ein Tracer? Ein Metrik-Zähler? Die Bibliothek soll das nicht wissen müssen.

`diagnostics_channel` ist seit Node v18.13.0 stabil und genau dafür da: ein entkoppelter Publish/Subscribe-Kanal. Produzenten veröffentlichen Nachrichten auf einem benannten Kanal, Beobachter abonnieren ihn. Beide Seiten kennen sich nicht.

```js
import diagnostics_channel from 'node:diagnostics_channel';

const channel = diagnostics_channel.channel('app:request:start');

function handleRequest(req) {
  if (channel.hasSubscribers) {
    channel.publish({
      requestId: req.id,
      route: req.url,
      startedAt: Date.now(),
    });
  }
  // ... actual work
}
```

Auf der anderen Seite, in einem völlig getrennten Stück Code – vielleicht in der Observability-Schicht, vielleicht in einem Plugin – abonniert man denselben Kanal:

```js
import diagnostics_channel from 'node:diagnostics_channel';

diagnostics_channel.subscribe('app:request:start', (message, name) => {
  console.log(`[${name}] request ${message.requestId} on ${message.route}`);
});
```

Der `hasSubscribers`-Guard ist kein Detail, sondern der Kern der Sache. Ohne Abonnenten soll gar nichts kosten. Baut man das Nachrichtenobjekt bedingungslos – womöglich mit teuren Feldern oder serialisierten Payloads –, zahlt man diesen Preis auch dann, wenn niemand zuhört. Mit dem Guard fällt bei null Abonnenten praktisch kein Aufwand an.

Ein Hinweis zur API: Es gibt am Channel-Objekt auch `channel.subscribe(...)`, aber diese Variante ist seit v18.7.0 als veraltet markiert. Im eigenen Code nimmt man die Funktionen auf Modul-Ebene, also `diagnostics_channel.subscribe(name, onMessage)`.

## Drei Ebenen, drei Aufgaben

Die drei Module klingen ähnlich und tun Grundverschiedenes – genau hier entsteht die Verwirrung. Die Rollenverteilung:

- `AsyncLocalStorage` trägt Kontext: stabil seit v16.4.0, die praktische High-Level-API. Request-ID, User und Trace durch tiefe Async-Ketten – ohne Parameter-Durchreichen.
- `async_hooks` ist das Fundament: experimentell und ausdrücklich nicht zum direkten Gebrauch empfohlen. Es liefert den Ressourcen-Lebenszyklus, auf dem `AsyncLocalStorage` aufsetzt.
- `diagnostics_channel` meldet Ereignisse entkoppelt: stabil seit v18.13.0. Produzenten und Konsumenten kennen sich nicht – ideal für Instrumentierung und Tracing.

Das folgende Diagramm ordnet die drei zueinander an: die Kontext-Schicht auf ihrem Fundament, daneben der eigenständige Ereignis-Bus.

```mermaid
flowchart TB
  subgraph Context["Kontext tragen"]
    ALS["AsyncLocalStorage<br/>Stable"]
    AH["async_hooks<br/>Experimental"]
    ALS --> AH
  end
  subgraph Bus["Ereignisse entkoppelt melden"]
    Pub["Publisher"] --> Ch["diagnostics_channel"]
    Ch --> S1["Subscriber: Logger"]
    Ch --> S2["Subscriber: Tracer"]
  end
```

## Request-Korrelation als messbare Betriebsqualität

Die drei Module sind Mittel, kein Zweck. Der Zweck ist eine Eigenschaft des laufenden Systems: Lässt sich jeder Vorgang eindeutig einem Request zuordnen? Sobald das gegeben ist, wird aus einem diffusen „das System fühlt sich langsam an“ eine beantwortbare Frage. Man greift eine Correlation-ID, filtert Logs, Metriken und Traces darauf und sieht den einen Request in seiner ganzen Tiefe – über Service-Grenzen hinweg.

Diese Nachverfolgbarkeit ist messbar, und genau das macht sie zu einer Betriebsqualität statt zu einem Bauchgefühl. Der Anteil der Logzeilen, die einer Request-ID zugeordnet sind, ist eine Kennzahl. Die Zeit von der Fehlermeldung bis zur gefundenen Ursache – die Mean Time to Resolution – ist eine Kennzahl. Beide bewegen sich spürbar, wenn Korrelation lückenlos ist statt löchrig. `AsyncLocalStorage` liefert dafür den tragenden Baustein: den Schlüssel, der ohne Parameter-Durchreichen bis in die tiefste Funktion reicht. `diagnostics_channel` liefert den zweiten: die Entkopplung, mit der eine Observability-Schicht diesen Schlüssel abgreift, ohne dass der Anwendungscode wissen muss, wer zuhört.

Der Trade-off ist ehrlich zu benennen. Kontext über `AsyncLocalStorage` kostet einen kleinen, aber realen Overhead pro asynchroner Operation – für die allermeisten Backends vernachlässigbar gegenüber dem Gewinn, in Ausnahmefällen mit extrem hohem Durchsatz aber ein Punkt, den man misst statt annimmt. Der größere Preis ist Disziplin: Der `run`-Aufruf muss zuverlässig am Eingang jeder Ausführungskette sitzen, sonst reißt die Korrelation genau dort, wo man sie am dringendsten braucht.

Die Entscheidung fällt also nicht an der API, sondern am Anspruch. Eine Handvoll Endpunkte ohne echte Betriebslast kommt ohne all das aus. Sobald aber mehrere Services zusammenspielen, ein Bereitschaftsdienst existiert oder Fehler Geld kosten, wird Request-Korrelation vom Nice-to-have zur Grundlage. Sie ist der Baustein, auf dem Logging, Tracing und Alerting erst zu einem Bild zusammenwachsen – die Brücke von einzelnen Signalen zu echter Observability.

## Fallstricke aus der Praxis

Der häufigste Schmerz mit `AsyncLocalStorage` ist Kontextverlust: `getStore()` liefert plötzlich `undefined`, obwohl man sich sicher innerhalb eines `run`-Aufrufs wähnt. Das passiert typischerweise an bestimmten callback-basierten APIs oder bei eigenen, selbstgebauten „thenable“-Objekten, bei denen Node die asynchrone Verkettung nicht sauber mitverfolgen kann. Die Dokumentation nennt zwei Abhilfen: solche APIs mit `util.promisify()` in echte Promises überführen oder die Ressource mit `AsyncResource` binden. Praktischer Debug-Trick: An verdächtigen Übergängen `getStore()` loggen. Ist der Store direkt nach einem bestimmten Aufruf weg, hat man den Übergang gefunden, an dem die Verkettung reißt.

Beim `enterWith`-Leak habe ich es oben schon angedeutet – hier noch einmal konkret als Warnung: Der Kontext läuft über die gesamte weitere synchrone Ausführung und danach weiter. In einem Event-Handler gesetzt, können Folge-Handler denselben Kontext erben, obwohl sie zu einem ganz anderen Request gehören. Das sind dann Fehler, die man nachts um drei sucht. `run()` bevorzugen.

Und schließlich die Kosten von `async_hooks`: Der Overhead ist real, besonders durch das Promise-Tracking im `destroy`-Hook. Zusammen mit der Tatsache, dass die API experimentell und laut Node-Team riskant ist, ergibt das eine klare Linie – nicht als Kontext-Mechanismus in Produktion einsetzen. Wer Kontext braucht, nimmt `AsyncLocalStorage`. `async_hooks` ist etwas, das man kennt, um zu verstehen, was darunter passiert, und das man sonst in Ruhe lässt.

Als Randnotiz für die Neugierigen: Mit v18.16.0 kamen `AsyncLocalStorage.bind(fn)` und `AsyncLocalStorage.snapshot()` dazu, um den aktuellen Kontext an eine Funktion zu binden. Beides ist noch experimentell und für den Einstieg nicht nötig – ich erwähne es nur der Vollständigkeit halber.

## Fazit

Der rote Faden ist eine Schichtung. Ganz unten sorgt `async_hooks` dafür, dass Node den Lebenszyklus asynchroner Ressourcen überhaupt verfolgen kann – eine mächtige, aber experimentelle und teure Schicht, die man selten direkt anfasst. Darauf steht `AsyncLocalStorage` als die stabile, empfohlene API, mit der man request-bezogenen Kontext durch beliebig tiefe Aufrufketten trägt, ohne ihn durch jede Signatur zu fädeln. Und daneben steht `diagnostics_channel` als entkoppelter Ereignis-Bus, mit dem Bibliotheken und Anwendungen strukturiert instrumentieren, ohne dass Produzent und Konsument voneinander wissen müssen.

Die Wirkung zeigt sich weniger im Code als im Betrieb. Wo der Kontext sauber getragen wird, verkürzen sich die Zyklen in der Fehlersuche, weil jede Logzeile ihren Request kennt. Die Reibung sinkt, weil keine Signatur mehr einen Parameter durchschleift, den sie nie benutzt. Und die Qualität steigt messbar, weil Nachverfolgbarkeit vom Zufall zur Eigenschaft wird. Für die tägliche Arbeit heißt das: `als.run(store, cb)` oben, `als.getStore()` unten – und man ist das lästige Parameter-Durchreichen los. Wer Observability entkoppeln will, greift zu `diagnostics_channel` mit einem `hasSubscribers`-Guard. `async_hooks` bleibt das Fundament, das man verstanden haben sollte, aber im Alltag nicht selbst bedient. Die Grenze ist ebenso klar: Kein Mechanismus ersetzt die Disziplin, den `run`-Aufruf zuverlässig zu setzen – reißt die Kette dort, hilft die beste API nichts.

Wenn du an dieser Stelle tiefer in das Ereignismodell von Node einsteigen willst, passt der Beitrag über [EventEmitter und Ereignismodell](https://www.mikebild.dev/de/blog/nodejs-eventemitter-und-ereignismodell/) gut dazu. Und wer die Architektur rund um Event Loop und asynchrone Ausführung von Grund auf verstehen möchte, findet im [Node.js-Training](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/) den größeren Rahmen.

## Weiterführende Quellen

- [AsyncLocalStorage und Async-Kontext (Node.js v18.x)](https://nodejs.org/docs/latest-v18.x/api/async_context.html)
- [async_hooks (Node.js v18.x)](https://nodejs.org/docs/latest-v18.x/api/async_hooks.html)
- [diagnostics_channel (Node.js v18.x)](https://nodejs.org/docs/latest-v18.x/api/diagnostics_channel.html)
