Back

Keeping Context Across Async Calls in Node.js

Keeping Context Across Async Calls in Node.js

You’re three async calls deep into handling an HTTP request. You need the request ID for your logger, the user ID for your database query, and the tenant ID for your cache key. Do you pass them through every function signature? That gets messy fast.

Node.js has a clean solution built right in: AsyncLocalStorage.

Key Takeaways

  • AsyncLocalStorage from node:async_hooks propagates context across async boundaries without polluting function signatures.
  • It has been stable since Node.js 16.4.0 and is preferred over cls-hooked or direct use of the low-level async_hooks API.
  • Establish context once at the request entry point with run(), then read it anywhere using getStore().
  • Ideal for request IDs, tracing data, tenant metadata, and auth context — not for business logic state.
  • Watch out for context loss with non-native promises or legacy callback APIs, which util.promisify() typically resolves.

The Problem with Async Context Propagation

In synchronous code, you can use a simple global stack to track context. But async functions break that model. When a setTimeout fires or a Promise resolves, the original call stack is gone. A plain global variable would be shared across all concurrent requests — a serious bug waiting to happen in any real API server.

Before AsyncLocalStorage was stable, developers reached for libraries like cls-hooked or hand-rolled solutions using the lower-level async_hooks module. Both approaches are fragile. The raw async_hooks API is intentionally low-level and carries real performance overhead when misused. You shouldn’t be building on it directly for application code.

AsyncLocalStorage, part of node:async_hooks, is the recommended high-level API. It has been stable since Node.js 16.4.0 and is what frameworks like AdonisJS use internally to manage HTTP context.

How AsyncLocalStorage Works

AsyncLocalStorage works like thread-local storage from other languages — except Node.js is single-threaded, so the “thread” is replaced by an async execution context. Any async operation started inside a run() call inherits that context automatically, including setTimeout, Promise chains, and await calls.

import { AsyncLocalStorage } from 'node:async_hooks';

const requestContext = new AsyncLocalStorage();

You create one instance (typically as a module-level singleton), then use run() to establish a context at the entry point of each request.

A Realistic Request-Scoped Logging Example

Here’s a minimal Express middleware that attaches a request ID to every log line — without passing anything through function arguments:

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);

The key insight: fetchUserData never receives the request ID as a parameter. The context propagates automatically through the async boundary because it was established with run().

What to Store in Context

AsyncLocalStorage works well for cross-cutting concerns that are request-scoped but not part of your business logic:

  • Request IDs for distributed tracing and log correlation
  • Authenticated user or tenant metadata for multi-tenant applications
  • Trace context for tools like OpenTelemetry
  • Feature flags resolved at request time

Avoid storing large objects or anything that changes frequently. Keep the store small and treat it as read-mostly after initialization.

One Gotcha: Context Loss

Context can be lost when using non-native promise implementations or some older callback-based APIs. If getStore() returns undefined where you don’t expect it, check whether the async operation was started inside a run() call. Wrapping callback-based code with util.promisify() often helps, though some custom async resources may require AsyncResource.

Conclusion

AsyncLocalStorage solves a real problem elegantly. Instead of threading request metadata through every function call, you establish context once at the request boundary and read it wherever you need it. It’s the right tool for request-scoped logging, tracing, and auth context in any Node.js API or SSR application.

FAQs

There is a small overhead because Node.js must track async resources to propagate the store, but for typical web workloads the cost is negligible. Performance has improved significantly across recent Node.js versions, and the trade-off is usually worth it compared to manually threading context through every function call.

Yes. You can create separate instances for different concerns such as logging context, tracing, and tenant data. Each instance maintains its own independent store, so they will not interfere with each other. Just keep each instance as a module-level singleton so the same reference is used throughout your code.

Each worker thread has its own isolated AsyncLocalStorage state, so context does not cross thread boundaries. If you need to share request context with a worker, pass the relevant data explicitly through the worker's message channel and re-establish the store inside the worker with another run() call.

Explicit passing is more predictable and easier to test, but it clutters function signatures and pollutes intermediate layers that do not actually need the data. AsyncLocalStorage is best for cross-cutting concerns like logging and tracing, while business-critical data should still flow through arguments to keep code clear and testable.

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.

OpenReplay