Node.js AsyncLocalStorage: request-scoped context without globals

Carry correlation IDs, auth, and tenancy through async call chains in Node.js using AsyncLocalStorage. CLS patterns, pitfalls with worker threads, and testing strategies for production APIs.

Autor: Matheus Palma8 min de leitura
Node.jsTypeScriptBackendObservabilitySoftware architectureAPI design

You add OpenTelemetry to an HTTP service and wire a trace id into your logger. A few weeks later, a teammate refactors processOrder into smaller helpers, and suddenly logs from deep in the stack no longer include the request id—not because anyone removed it, but because threading context through parameters is tedious, so it gets dropped at one boundary. You consider a module-level currentRequestId and immediately remember why that fails under concurrency: two requests interleave, and logs lie.

AsyncLocalStorage (ALS), stabilized in Node.js 14+, solves this by binding arbitrary data to the logical async execution context that Node maintains across await, timers, and most continuation-passing patterns. Used well, it is the backbone of CLS (continuation-local storage) in frameworks like Express middleware stacks and Fastify’s request object plumbing. Used poorly, it hides dependencies and makes tests brittle.

This article walks through how ALS works under the hood at a practical level, how to shape a small request context API for production services, and where the model breaks (worker threads, some native addons, unusual scheduling). In client work on multi-tenant APIs, getting this layer right is what keeps structured logs, tracing, and authorization checks aligned when the codebase grows.

What AsyncLocalStorage gives you

AsyncLocalStorage stores a value that is visible only to code running as part of the async chain that entered a particular run() (or enterWith()) scope. Node’s async_hooks machinery propagates the store across:

  • async/await continuations
  • Promise.then chains
  • many setImmediate / process.nextTick continuations
  • most built-in I/O callbacks

Conceptually, think of each incoming HTTP request as spawning a tree of async work. ALS attaches a bag of data to the root of that tree; every descendant async operation can read the bag without threading it through every function signature.

Why not pass ctx everywhere?

Explicit context parameters are the cleanest design in small modules. They scale poorly across:

  • cross-cutting middleware (logging, metrics, feature flags)
  • deeply nested domain code that should not know about HTTP
  • third-party libraries you cannot change

ALS is a pragmatic compromise: implicit for ergonomics, but still scoped—unlike a process-wide global, two concurrent requests do not clobber each other’s values as long as they stay on distinct async trees.

Core API: run, getStore, and the dangerous enterWith

The safe pattern for HTTP servers is run per request:

import { AsyncLocalStorage } from "node:async_hooks";

type RequestContext = {
  requestId: string;
  tenantId: string;
};

export const requestStore = new AsyncLocalStorage<RequestContext>();

export function withRequestContext<T>(
  ctx: RequestContext,
  fn: () => T
): T {
  return requestStore.run(ctx, fn);
}

export function getRequestContext(): RequestContext | undefined {
  return requestStore.getStore();
}

At the edge of your system (HTTP adapter, message consumer, job runner), you wrap the unit of work:

// Pseudocode: HTTP framework handler
async function handle(req, res) {
  const ctx = {
    requestId: req.headers["x-request-id"] ?? randomUUID(),
    tenantId: await resolveTenant(req),
  };

  await new Promise<void>((resolve, reject) => {
    requestStore.run(ctx, () => {
      handleImpl(req, res).then(resolve, reject);
    });
  });
}

AsyncLocalStorage.prototype.run(store, callback) sets store for the duration of callback and for any async work scheduled while callback runs (subject to the propagation rules below). After the tree settles, the association is torn down.

getStore() returns the current store or undefined if called outside any run/enterWith scope.

enterWith: use sparingly

enterWith sets the store for the current synchronous execution and future async continuations from this point, without a nested run boundary. It is easy to leak context across unrelated work if you call it at the wrong time (for example, at module top level or inside a shared connection pool callback). Prefer run per unit of work unless you have a very controlled initialization path (some test harnesses use enterWith for convenience).

Designing a request context object

Keep the store small, immutable, and boring:

  • Identifiers: request id, trace id (or a reference to the active span), tenant id, authenticated subject id.
  • Capabilities, not ORM sessions: pass references to services that already enforce authorization, not raw “skip checks” flags.
  • Avoid large mutable objects that encourage “mid-request mutation” races when someone accidentally shares sub-objects across requests.

A pattern that has held up on several production Node gateways:

type RequestContext = Readonly<{
  requestId: string;
  tenantId: string;
  userId: string | null;
  // Optional: narrow interface your logger expects
  logBindings: Record<string, string | number | boolean>;
}>;

export function getRequestContext(): RequestContext {
  const s = requestStore.getStore();
  if (!s) {
    throw new Error("No request context — called outside withRequestContext?");
  }
  return s;
}

export function tryGetRequestContext(): RequestContext | undefined {
  return requestStore.getStore();
}

Throwing vs returning undefined is a product decision: strict get catches accidental use outside a request; tryGet suits shared utilities that also run in cron jobs.

Wiring logging and tracing

Structured logging libraries often support child loggers with default bindings. ALS lets you create the child once per request:

import pino from "pino";

const rootLogger = pino();

export function createRequestLogger(ctx: RequestContext) {
  return rootLogger.child(ctx.logBindings);
}

In handlers, you might stash the child logger on the context or retrieve it via a factory. The important part is that domain code calls getRequestContext() (or receives a logger derived from it) instead of importing a mutable let currentLogger.

For OpenTelemetry, ALS pairs well with custom context managers or framework instrumentation that already propagates trace context; the request id in ALS should match the span id or trace id you emit in logs so support teams can pivot from logs to traces in one click.

Trade-offs and limitations

Hidden dependencies

ALS makes context implicit. New engineers cannot see from a function signature that chargeWallet() will read tenantId from ALS. Mitigations:

  • keep ALS reads localized (logging, tracing, tenancy guard in one module)
  • document the invariant: “all HTTP entrypoints call withRequestContext
  • in code review, flag domain functions that call getRequestContext() deeply—sometimes pushing tenantId back into explicit parameters is clearer

Worker threads and vm contexts

Worker threads have separate ALS namespaces; stores do not cross the thread boundary. If you offload CPU work to worker_threads, pass ids explicitly in messages.

Some native addons or unusual scheduling can break propagation; when integrating C++ extensions that schedule callbacks outside Node’s normal mechanisms, validate with a stress test that logs the same requestId before and after the native hop.

Multiple nested run calls

Nested run calls behave like a stack: inner run shadows outer stores until the inner callback completes. That is usually what you want for sub-tasks. Be careful if you fork background work that should not inherit the HTTP context (e.g. a fire-and-forget audit job): explicitly clear or replace context in that branch, or schedule it through a queue that carries its own payload.

Practical example: Express-style middleware

Below is a minimal Express pattern (the same idea applies to Fastify onRequest hooks or Node’s native http.createServer).

import express from "express";
import { randomUUID } from "node:crypto";
import { AsyncLocalStorage } from "node:async_hooks";

type RequestContext = Readonly<{
  requestId: string;
  tenantId: string;
}>;

const requestStore = new AsyncLocalStorage<RequestContext>();

function getRequestContext(): RequestContext {
  const s = requestStore.getStore();
  if (!s) throw new Error("Missing request context");
  return s;
}

const app = express();

app.use((req, res, next) => {
  const ctx: RequestContext = {
    requestId: (req.headers["x-request-id"] as string) ?? randomUUID(),
    tenantId: (req.headers["x-tenant-id"] as string) ?? "unknown",
  };

  requestStore.run(ctx, () => {
    res.setHeader("x-request-id", ctx.requestId);
    next();
  });
});

app.get("/orders/:id", async (req, res) => {
  const { requestId, tenantId } = getRequestContext();
  const order = await loadOrderForTenant(tenantId, req.params.id);
  res.json({ requestId, order });
});

async function loadOrderForTenant(tenantId: string, orderId: string) {
  // Deep in the stack — still has ALS available
  const { requestId } = getRequestContext();
  // ... database call, logging with requestId, etc.
  return { id: orderId, tenantId, trace: requestId };
}

app.listen(3000);

Note next() is invoked inside run, so downstream middleware and route handlers remain on the same async tree.

Common mistakes and pitfalls

  1. Calling next() outside run — If you exit the run callback before Express continues the chain, later handlers may lose the store. Always structure middleware so the entire request lifecycle is enclosed.

  2. Starting async work before run — If you begin a Promise outside and only enter run in .then, the continuation might not inherit the store you expect. Enter run before scheduling work that should carry context.

  3. Treating ALS as a security boundary — ALS does not enforce isolation; it is not a substitute for authorization checks at data access boundaries. It only carries hints like tenantId.

  4. Over-stuffing the store — Large objects and heavy per-request allocations increase GC pressure. Keep it lean.

  5. Forgetting non-HTTP entrypoints — CLI scripts, queue consumers, and cron tasks also benefit from run per message so the same domain code works in all environments.

If you are standardizing observability or tenancy across a growing Node surface, it helps to treat ALS as part of the platform contract—documented, reviewed, and tested like any shared library. For background on production-minded backend work, see About; for architecture reviews or collaboration on scalable APIs, use Contact.

Testing strategies

  • Unit tests: wrap the system under test with requestStore.run(fakeCtx, () => { ... }).
  • Integration tests: assert that log lines include requestId by driving HTTP with a known x-request-id header.
  • Concurrency tests: fire N parallel requests and assert ids never cross—this catches accidental globals.

Conclusion

AsyncLocalStorage is the right tool when you need request-scoped state in a highly concurrent Node service without threading a ctx parameter through every layer. It keeps logging, tracing, and tenancy metadata consistent across async refactors—as long as you treat it as infrastructure, keep stores small, and respect its propagation limits across workers and native boundaries.

The operational payoff shows up late: incidents are faster to triage, and onboarding stays tractable when cross-cutting concerns have a single, well-documented entrypoint. That is the kind of foundation worth investing in when you expect the API surface—and the team—to keep growing.

Assine a newsletter

Receba um e-mail quando novos artigos forem publicados. Sem spam — apenas novos posts deste blog.

Via Resend. Você pode cancelar a inscrição em qualquer e-mail.