Request-Kontext in Node.js mit AsyncLocalStorage
Nutze AsyncLocalStorage, um Request-Kontext — Trace-IDs, Sitzungen, Mandantendaten — durch asynchrone Aufrufketten zu propagieren, statt ihn zu reichen.

Jeder nicht-triviale Node.js-Service stößt irgendwann an dieselbe Wand: Du brauchst eine Trace-ID, Tenant-ID oder den authentifizierten Benutzer sechs Ebenen tief im Call Stack, in einem Utility, das mit HTTP-Requests nichts zu tun haben sollte. Der instinktive Fix ist, jeder Funktion zwischen Request-Handler und diesem Utility einen ctx-Parameter zu verpassen. Drei Monate später ist der ctx-Typ auf fünfzehn Felder angewachsen, die Hälfte deiner Funktionssignaturen beginnt damit, und ein neuer Kontextwert bedeutet, ein Dutzend Dateien anzufassen.
AsyncLocalStorage — stabil seit Node 16, seit Node 12 hinter einem Flag verfügbar — löst das sauber. Es ist das Node.js-Äquivalent zu Thread-Local Storage: ein Wert, der an einen asynchronen Ausführungskontext gebunden ist und automatisch durch await, setTimeout, Promise.then und Event-Emitter-Callbacks fließt, ganz ohne explizite Parameterübergabe.
Das Problem mit dem Parameter-Durchreichen
Vor dem Blick auf die Lösung lohnt es sich, die Kosten des Status quo konkret zu machen.
// ❌ Context bleeds into every layer's signature
async function handleCheckout(req: Request, res: Response) {
const ctx: RequestCtx = { traceId: req.headers["x-trace-id"] as string, userId: req.user.id };
const result = await processOrder(ctx, req.body.orderId);
res.json(result);
}
async function processOrder(ctx: RequestCtx, orderId: string) {
await validateInventory(ctx, orderId); // ctx passed down
await chargePayment(ctx, orderId); // ctx passed down
await sendConfirmation(ctx, orderId); // ctx passed down
}
async function validateInventory(ctx: RequestCtx, orderId: string) {
// ctx finally consumed — just for one log line
logger.info("stock check", { traceId: ctx.traceId, userId: ctx.userId });
}Jede Funktion zwischen dem Handler und dem eigentlichen Konsumenten des Kontexts wird zum stumpfen Kurier. Schlimmer noch: Wer tenantId zu RequestCtx hinzufügt, muss jetzt processOrder, validateInventory, chargePayment und sendConfirmation anfassen — obwohl keine dieser Funktionen sich dafür interessiert.
Wie AsyncLocalStorage funktioniert
AsyncLocalStorage erzeugt einen Store, der automatisch von jeder asynchronen Operation geerbt wird, die innerhalb eines run()-Aufrufs gestartet wird. Die Laufzeitumgebung verfolgt, welcher Store zu welcher asynchronen Ausführungskette gehört, und hält sie voneinander isoliert.
import { AsyncLocalStorage } from "node:async_hooks";
const store = new AsyncLocalStorage<{ traceId: string }>();
store.run({ traceId: "abc-123" }, async () => {
await someDeepUtility(); // store is accessible here
});
async function someDeepUtility() {
const ctx = store.getStore();
console.log(ctx?.traceId); // "abc-123" — no parameter needed
}Gleichzeitige Requests bekommen jeweils ihre eigene Store-Instanz. Es gibt keinen geteilten Zustand und kein Risiko, dass der Kontext eines Requests in einen anderen übergreift.
Ein typsicheres Kontext-Modul bauen
Ein dünner Wrapper um AsyncLocalStorage liefert dir eine typisierte, ergonomische API für die gesamte Codebasis.
import { AsyncLocalStorage } from "node:async_hooks";
export interface RequestContext {
traceId: string;
requestId: string;
userId?: string;
tenantId?: string;
}
const storage = new AsyncLocalStorage<RequestContext>();
export function runWithContext<T>(ctx: RequestContext, fn: () => T): T {
return storage.run(ctx, fn);
}
export function getContext(): RequestContext {
const ctx = storage.getStore();
if (!ctx) {
throw new Error(
"getContext() called outside a request context. " +
"Ensure runWithContext() wraps the call chain."
);
}
return ctx;
}
export function getContextOrNull(): RequestContext | null {
return storage.getStore() ?? null;
}Das Werfen eines Fehlers in getContext(), wenn kein Store existiert, ist Absicht. Stille undefined-Rückgaben verstecken den Bug; ein expliziter Fehler bringt ihn während der Entwicklung sofort ans Licht.
In die Request-Pipeline einbinden
Der richtige Ort für den Aufruf von runWithContext ist deine Framework-Middleware — einmal pro Request, bevor irgendwelche asynchrone Arbeit beginnt.
import { randomUUID } from "node:crypto";
import type { Request, Response, NextFunction } from "express";
import { runWithContext } from "./context.js";
export function contextMiddleware(
req: Request,
res: Response,
next: NextFunction,
): void {
const traceId =
(req.headers["x-trace-id"] as string | undefined) ?? randomUUID();
const requestId = randomUUID();
// Stamp the trace ID onto the response so clients can correlate logs
res.setHeader("x-trace-id", traceId);
runWithContext(
{
traceId,
requestId,
userId: req.user?.id,
tenantId: req.user?.tenantId,
},
() => next(),
);
}Registriere sie früh in deiner Middleware-Kette, vor allen Route-Handlern:
app.use(contextMiddleware);
app.use("/api", router);Jeder nachgelagerte Handler, Service und jedes Utility, das innerhalb dieses Requests aufgerufen wird, hat jetzt Zugriff auf den Kontext — ganz ohne Parameter.
Kontext in Utilities verwenden
Mit der Middleware an Ort und Stelle kann jedes Utility den Kontext direkt abrufen.
// ✅ Logger auto-attaches trace context — no ctx parameter needed
import { getContextOrNull } from "./context.js";
import pino from "pino";
const baseLogger = pino({ level: "info" });
export const logger = {
info: (msg: string, data?: Record<string, unknown>) => {
const ctx = getContextOrNull();
baseLogger.info({ ...data, ...ctx }, msg);
},
error: (msg: string, error: unknown, data?: Record<string, unknown>) => {
const ctx = getContextOrNull();
baseLogger.error({ ...data, ...ctx, err: error }, msg);
},
};Die Datenzugriffsschicht profitiert genauso:
// ❌ Previously required ctx threading just for audit logging
export async function updateUserEmail(
ctx: RequestContext,
userId: string,
email: string,
): Promise<void> { /* ... */ }
// ✅ Signature reflects only what the function actually operates on
export async function updateUserEmail(userId: string, email: string): Promise<void> {
const { traceId, tenantId } = getContext();
await db.query(
"UPDATE users SET email = $1 WHERE id = $2 AND tenant_id = $3",
[email, userId, tenantId],
);
await auditLog.record({ event: "email_updated", userId, traceId });
}Die Funktionssignatur dokumentiert jetzt, was die Funktion tut — nicht, was sie für andere mitschleppen muss.
Die Falle der losgelösten Promises
AsyncLocalStorage propagiert den Kontext durch asynchrone Operationen, die innerhalb des run()-Scopes mit await erwartet werden. Ein Fire-and-Forget-Muster durchbricht das.
// ❌ Detached promise loses context — getContext() will throw inside sendWelcomeEmail
export async function registerUser(email: string): Promise<User> {
const user = await createUser(email);
sendWelcomeEmail(user); // NOT awaited — detached from the request context
return user;
}
// ✅ Capture context before detaching, or use a queue
export async function registerUser(email: string): Promise<User> {
const user = await createUser(email);
const ctx = getContext(); // capture while still in scope
setImmediate(() => {
// Re-enter the context for the detached work
runWithContext(ctx, () => sendWelcomeEmail(user));
});
return user;
}Dieselbe Regel gilt für Hintergrundjobs, die über setTimeout geplant werden, oder für Consumer einer Message Queue: Sie starten einen frischen Ausführungskontext. Erfasse, was du brauchst, bevor du die Request-Grenze verlässt, oder stelle den Kontext explizit wieder her, wenn der Job läuft.
Für Hintergrundjobs ist es ein besseres Muster, die benötigten Kontextwerte als Teil des Job-Payloads zu speichern — { traceId, tenantId, ...jobData } — und runWithContext() am Anfang des Job-Handlers aufzurufen. Das macht den Kontext an der Consumer-Grenze explizit und übersteht Prozessneustarts.
Die wichtigsten Erkenntnisse
AsyncLocalStorageeliminiert Kontextparameter — Trace-IDs, Tenant-IDs und Benutzersitzungen gehören in den Kontext-Store, nicht in Funktionssignaturen- Eine Middleware, volle Abdeckung — ein einziger
runWithContext()-Aufruf pro Request macht den Kontext im gesamten asynchronen Baum dieses Requests verfügbar - Wirf einen Fehler, wenn der Store fehlt — stille
undefined-Rückgaben vongetStore()verstecken Bugs; ein expliziter Fehler bringt Fehlkonfiguration sofort ans Licht - Losgelöste Promises brechen die Propagation — Fire-and-Forget-Operationen verlassen den ursprünglichen Ausführungskontext; erfasse, was du brauchst, bevor du sie abkoppelst, oder stelle den Kontext explizit wieder her
- Logger sind der größte Gewinn — ein kontextbewusster Logger, der
traceIdundtenantIdautomatisch in jede Zeile aufnimmt, erfordert null Änderungen an den Aufrufstellen - Funktionssignaturen werden sauberer — Business-Logik-Funktionen hören auf, Kurier-Argumente mitzuschleppen, und spiegeln nur noch wider, worauf sie tatsächlich operieren


