Kontext über asynchrone Aufrufe hinweg in Node.js bewahren
Sie befinden sich drei asynchrone Aufrufe tief in der Verarbeitung einer HTTP-Anfrage. Sie benötigen die Request-ID für Ihren Logger, die User-ID für Ihre Datenbankabfrage und die Tenant-ID für Ihren Cache-Schlüssel. Reichen Sie diese Werte durch jede Funktionssignatur durch? Das wird schnell unübersichtlich.
Node.js bietet eine elegante Lösung, die bereits eingebaut ist: AsyncLocalStorage.
Wichtige Erkenntnisse
AsyncLocalStorageausnode:async_hookspropagiert Kontext über asynchrone Grenzen hinweg, ohne Funktionssignaturen zu überladen.- Es ist seit Node.js 16.4.0 stabil und wird gegenüber
cls-hookedoder der direkten Verwendung der Low-Level-APIasync_hooksbevorzugt. - Etablieren Sie den Kontext einmalig am Einstiegspunkt der Anfrage mit
run()und lesen Sie ihn anschließend überall mitgetStore()aus. - Ideal für Request-IDs, Tracing-Daten, Tenant-Metadaten und Auth-Kontext – nicht für den Zustand der Geschäftslogik.
- Achten Sie auf Kontextverlust bei nicht-nativen Promises oder älteren Callback-APIs, was sich in der Regel mit
util.promisify()beheben lässt.
Das Problem der asynchronen Kontextpropagierung
In synchronem Code lässt sich ein einfacher globaler Stack zur Kontextverfolgung verwenden. Doch asynchrone Funktionen durchbrechen dieses Modell. Sobald ein setTimeout ausgelöst wird oder ein Promise aufgelöst wird, ist der ursprüngliche Call Stack verloren. Eine schlichte globale Variable würde zwischen allen gleichzeitig laufenden Anfragen geteilt – ein gravierender Bug, der in jedem realen API-Server nur darauf wartet, zuzuschlagen.
Bevor AsyncLocalStorage stabil wurde, griffen Entwickler auf Bibliotheken wie cls-hooked oder selbstgebaute Lösungen mit dem Low-Level-Modul async_hooks zurück. Beide Ansätze sind fragil. Die rohe async_hooks-API ist bewusst Low-Level und bringt bei falscher Nutzung einen spürbaren Performance-Overhead mit sich. Für Anwendungscode sollten Sie nicht direkt darauf aufbauen.
AsyncLocalStorage, Teil von node:async_hooks, ist die empfohlene High-Level-API. Sie ist seit Node.js 16.4.0 stabil und wird intern von Frameworks wie AdonisJS zur Verwaltung des HTTP-Kontexts verwendet.
Wie AsyncLocalStorage funktioniert
AsyncLocalStorage funktioniert ähnlich wie Thread-Local Storage in anderen Sprachen – nur dass Node.js single-threaded ist, weshalb der „Thread” durch einen asynchronen Ausführungskontext ersetzt wird. Jede asynchrone Operation, die innerhalb eines run()-Aufrufs gestartet wird, erbt diesen Kontext automatisch, einschließlich setTimeout, Promise-Ketten und await-Aufrufen.
import { AsyncLocalStorage } from 'node:async_hooks';
const requestContext = new AsyncLocalStorage();
Sie erstellen eine Instanz (typischerweise als Singleton auf Modulebene) und verwenden anschließend run(), um am Einstiegspunkt jeder Anfrage einen Kontext zu etablieren.
Ein realistisches Beispiel für request-scoped Logging
Hier ist eine minimale Express-Middleware, die jeder Log-Zeile eine Request-ID anhängt – ohne irgendetwas durch Funktionsargumente weiterzureichen:
import express from 'express';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
const requestContext = new AsyncLocalStorage();
// Middleware: establish context for each request
function contextMiddleware(req, res, next) {
const store = { requestId: randomUUID(), userId: req.headers['x-user-id'] };
requestContext.run(store, next);
}
// Logger: reads context without any arguments
function log(message) {
const ctx = requestContext.getStore();
const prefix = ctx ? `[${ctx.requestId}]` : '[no-context]';
console.log(`${prefix} ${message}`);
}
// Simulated async database query
async function someDbQuery() {
return new Promise((resolve) => setTimeout(resolve, 50));
}
// Route handler: calls async functions freely
async function fetchUserData() {
log('Fetching user data'); // ✅ has request ID
await someDbQuery();
log('Fetched user data'); // ✅ still has request ID
}
const app = express();
app.use(contextMiddleware);
app.get('/user', async (req, res) => {
log('Request received');
await fetchUserData();
res.json({ ok: true });
});
app.listen(3000);
Der entscheidende Punkt: fetchUserData erhält die Request-ID niemals als Parameter. Der Kontext propagiert automatisch über die asynchrone Grenze hinweg, weil er mit run() etabliert wurde.
Discover how at OpenReplay.com.
Was sollte im Kontext gespeichert werden?
AsyncLocalStorage eignet sich hervorragend für übergreifende Anliegen (Cross-Cutting Concerns), die request-scoped sind, aber nicht zur Geschäftslogik gehören:
- Request-IDs für verteiltes Tracing und Log-Korrelation
- Authentifizierte Benutzer- oder Tenant-Metadaten für mandantenfähige Anwendungen
- Trace-Kontext für Tools wie OpenTelemetry
- Feature Flags, die zur Request-Zeit aufgelöst werden
Vermeiden Sie es, große Objekte oder häufig veränderliche Daten zu speichern. Halten Sie den Store klein und behandeln Sie ihn nach der Initialisierung als überwiegend lesbar.
Eine Stolperfalle: Kontextverlust
Der Kontext kann verloren gehen, wenn nicht-native Promise-Implementierungen oder einige ältere Callback-basierte APIs verwendet werden. Wenn getStore() an einer unerwarteten Stelle undefined zurückgibt, prüfen Sie, ob die asynchrone Operation innerhalb eines run()-Aufrufs gestartet wurde. Das Umhüllen von Callback-basiertem Code mit util.promisify() hilft oft, einige benutzerdefinierte asynchrone Ressourcen erfordern jedoch möglicherweise AsyncResource.
Fazit
AsyncLocalStorage löst ein reales Problem auf elegante Weise. Anstatt Request-Metadaten durch jeden Funktionsaufruf zu schleusen, etablieren Sie den Kontext einmalig an der Request-Grenze und lesen ihn dort aus, wo Sie ihn benötigen. Es ist das richtige Werkzeug für request-scoped Logging, Tracing und Auth-Kontext in jeder Node.js-API oder SSR-Anwendung.
FAQs
Es gibt einen geringen Overhead, da Node.js asynchrone Ressourcen verfolgen muss, um den Store zu propagieren, aber für typische Web-Workloads sind die Kosten vernachlässigbar. Die Performance hat sich in den letzten Node.js-Versionen deutlich verbessert, und der Kompromiss lohnt sich in der Regel im Vergleich zum manuellen Durchreichen des Kontexts durch jeden Funktionsaufruf.
Ja. Sie können separate Instanzen für unterschiedliche Anliegen wie Logging-Kontext, Tracing und Tenant-Daten erstellen. Jede Instanz verwaltet ihren eigenen unabhängigen Store, sodass sie sich nicht gegenseitig beeinflussen. Halten Sie jede Instanz lediglich als Singleton auf Modulebene, damit überall in Ihrem Code dieselbe Referenz verwendet wird.
Jeder Worker-Thread hat seinen eigenen isolierten AsyncLocalStorage-Zustand, der Kontext überschreitet also keine Thread-Grenzen. Wenn Sie den Request-Kontext mit einem Worker teilen müssen, übergeben Sie die relevanten Daten explizit über den Message-Channel des Workers und etablieren den Store innerhalb des Workers mit einem weiteren run()-Aufruf erneut.
Explizites Durchreichen ist vorhersehbarer und einfacher zu testen, überfrachtet jedoch Funktionssignaturen und verunreinigt Zwischenschichten, die die Daten eigentlich gar nicht benötigen. AsyncLocalStorage eignet sich am besten für übergreifende Anliegen wie Logging und Tracing, während geschäftskritische Daten weiterhin über Argumente fließen sollten, um den Code klar und testbar zu halten.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.