Async-Kontext in Node.js: AsyncLocalStorage, async_hooks und diagnostics_channel
Wie man request-bezogenen Kontext wie Request-ID, User und Trace durch tiefe asynchrone Aufrufketten trägt, ohne ihn durch jede Funktionssignatur zu fädeln – mit AsyncLocalStorage, dem darunterliegenden async_hooks und diagnostics_channel für entkoppeltes Tracing.
Im letzten Beitrag über Node.js debuggen und beobachten habe ich am Ende versprochen, mir das Thema Async-Kontext genauer anzusehen. Genau das hole ich hier nach. In fast jeder Backend-Schulung, die ich gebe, kommt irgendwann die gleiche Frage aus dem Publikum: „Ich habe eine Request-ID ganz oben im HTTP-Handler. Wie bekomme ich die jetzt in den Logger, der fünf Funktionsaufrufe tiefer steckt – ohne sie durch jede einzelne Signatur zu reichen?"
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.
function handler(req, res) {
const requestId = randomUUID();
serviceA(req.body, requestId);
}
function serviceA(input, requestId) {
// serviceA braucht requestId selbst gar nicht
return repository(input, requestId);
}
function repository(input, requestId) {
// repository auch nicht
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.
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.
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. In Schulungen sage ich schlicht: Fang mit run an. enterWith ist etwas für seltene Sonderfälle, 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.
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.
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(),
});
}
// ... eigentliche Arbeit
}
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:
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
Ich fasse die Rollenverteilung zusammen, weil genau hier in Schulungen die Verwirrung entsteht – die drei Module klingen ähnlich, tun aber Grundverschiedenes:
AsyncLocalStorageträ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_hooksist das Fundament: experimentell und ausdrücklich nicht zum direkten Gebrauch empfohlen. Es liefert den Ressourcen-Lebenszyklus, auf demAsyncLocalStorageaufsetzt.diagnostics_channelmeldet 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.
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
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.
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 zusätzlich Observability entkoppeln will, greift zu diagnostics_channel mit einem hasSubscribers-Guard. Und async_hooks bleibt das Fundament, das man verstanden haben sollte, aber im Alltag nicht selbst bedient.
Wenn du an dieser Stelle tiefer in das Ereignismodell von Node einsteigen willst, passt der Beitrag über 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 den größeren Rahmen.
Kommentare
Kommentar schreiben