JavaScript Basics: Carry Request Context with AsyncLocalStorage
Passing a request ID through every function can clutter otherwise small APIs. Node.js AsyncLocalStorage lets asynchronous work retrieve the context associated with its execution. These examples use local tasks instead of a web server so the context boundaries are easy to see.
These three independent examples were executed on Linux with Node.js v24.19.0. Use Node.js 24 for this tutorial; only the installed version above was tested. They use built-in modules, make no network requests, and assert their results. The output below is exact, not a sketch.
Prepare a scratch directory
You need Bash, Node.js on your PATH, a text editor, and the mktemp, rm, and rmdir utilities. Keep the same terminal open and save each named file as UTF-8 in the new directory. The .mjs extension selects an ES module.
node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1The tested runtime prints:
v24.19.01. Keep concurrent task IDs separate
Save this as track-concurrent-contexts.mjs:
import assert from 'node:assert/strict';
import { AsyncLocalStorage } from 'node:async_hooks';
import { setTimeout as delay } from 'node:timers/promises';
const context = new AsyncLocalStorage();
async function inspect() {
await delay(1);
return context.getStore().requestId;
}
const ids = await Promise.all(['request-a', 'request-b'].map(requestId =>
context.run({ requestId }, inspect)
));
assert.deepEqual(ids, ['request-a', 'request-b']);
assert.equal(context.getStore(), undefined);
console.log(ids.join(', '));
console.log('outside context: undefined');Run it:
node track-concurrent-contexts.mjsOutput:
request-a, request-b
outside context: undefinedEach run starts a separate context, and the timer continuation can read its request ID. Promise.all returns results in input order, so this output does not depend on which timer finishes first. Calling getStore outside either run returns undefined. In application code, a logger could read this ID without receiving it as an argument. See the official run and getStore documentation.
2. Override context for one nested operation
Save this as scope-nested-context.mjs:
import assert from 'node:assert/strict';
import { AsyncLocalStorage } from 'node:async_hooks';
const context = new AsyncLocalStorage();
const steps = [];
await context.run({ requestId: 'request-a', stage: 'request' }, async () => {
steps.push(context.getStore().stage);
await context.run({ ...context.getStore(), stage: 'validation' }, async () => {
await Promise.resolve();
steps.push(context.getStore().stage);
});
steps.push(context.getStore().stage);
});
assert.deepEqual(steps, ['request', 'validation', 'request']);
console.log(steps.join(' -> '));Run it:
node scope-nested-context.mjsOutput:
request -> validation -> requestThe inner operation gets a new store object with the same request ID and a different stage. When control resumes in the outer context, its stage is unchanged. The spread here is only a shallow copy: nested objects would still be shared. AsyncLocalStorage does not automatically clone or freeze your store, so avoid mutating a shared store when sibling tasks need independent values.
3. Capture context for a callback invoked later
Save this as capture-context-callback.mjs:
import assert from 'node:assert/strict';
import { AsyncLocalStorage } from 'node:async_hooks';
const context = new AsyncLocalStorage();
const invokeInContext = context.run({ requestId: 'request-c' }, () =>
AsyncLocalStorage.snapshot()
);
const readId = () => context.getStore()?.requestId ?? 'none';
assert.equal(readId(), 'none');
const captured = await Promise.resolve().then(() => invokeInContext(readId));
assert.equal(captured, 'request-c');
assert.equal(readId(), 'none');
console.log(`captured: ${captured}`);
console.log(`outside: ${readId()}`);Run it:
node capture-context-callback.mjsOutput:
captured: request-c
outside: nonesnapshot captures the current execution context and returns a function that invokes another function inside it. This is useful when handing callbacks to code that will invoke them from an unrelated context. It captures context, not an immutable deep copy of application data. Snapshot became stable in Node.js 23.11.0 and 22.15.0; this tutorial targets Node.js 24. See snapshot.
Practical limits
Use run for explicit boundaries rather than assuming all callbacks inherit the context you intended. Some custom callback integrations can lose context and need AsyncResource; inspect getStore near the boundary when debugging. Context is bookkeeping, not an authorization boundary, and it does not automatically propagate to other processes or worker threads.
Clean up
In the same terminal, remove only the three named source files, return to your starting directory, and remove the now-empty scratch directory:
rm -- track-concurrent-contexts.mjs scope-nested-context.mjs capture-context-callback.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirAcknowledgements
This post was created with assistance from AI.