Prerequisites
- Node.js v22 or later (AsyncLocalStorage as documented in v22)
- Familiarity with async/await and Promises
Implementation decision
Choose AsyncLocalStorage.run(store, callback) instead of a shared mutable variable. The implementation uses a fresh AsyncLocalStorage instance and deterministic fixtures: requests created by run() each await manually resolved promises so you can control interleaving. Record getStore() at synchronous entry and after awaited pauses to assert isolation. Also include a nested-run fixture to confirm that an inner run() temporarily needs its own store and that the outer store is restored when the inner finishes. Add a throwing-run fixture to verify that exceptions exit and restore context.
Expected result
- Overlapped runs each observe only their own store at every checkpoint even when their async continuations are interleaved. This proves run() attaches the store to the async chain started in the callback.
- Nested run() temporarily swaps stores for the inner callback and restores the outer store when the inner callback finishes.
- After a thrown error inside run(), the context is exited and getStore() returns undefined.
One limitation
AsyncLocalStorage provides logical request-scoped context, not security or cross-process identity. Treat secrets and cross-service propagation explicitly; don’t rely on context propagation as a protection mechanism.
Server will insert the verified code example below.
Working example
import { AsyncLocalStorage } from 'node:async_hooks';
// This module exports three factory functions that create controllable
// async scenarios using the real Node.js v22 AsyncLocalStorage API.
// Nothing runs on import.
// Helper: create an externally-resolvable promise
function makeDeferred() {
let resolve, reject;
const p = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise: p, resolve, reject };
}
// Each spawn* function returns an object with
// - controllers: the external resolvers the test will call to drive progress
// - finished: a promise that resolves when the async scenario completes or rejects if it throws
// - records: a promise that resolves to an array of observed store values in sequence
export function spawnRequest(asyncLocalStorage, id) {
// Two manual async hops controlled from the outside
const step1 = makeDeferred();
const step2 = makeDeferred();
// The recorded values observed inside the async run
// We record at three moments: immediately inside run, after awaiting step1, after awaiting step2.
const records = [];
const finished = asyncLocalStorage.run(id, async () => {
// immediately visible
records.push(asyncLocalStorage.getStore());
// create two promise-based async hops; the test resolves them to control interleaving
await step1.promise;
records.push(asyncLocalStorage.getStore());
await step2.promise;
records.push(asyncLocalStorage.getStore());
return records;
});
// run() returns the callback's return value (a Promise here) synchronously
// but it executes the callback and the contained async-await chain inside the provided store.
return {
controllers: { resolveStep1: step1.resolve, resolveStep2: step2.resolve },
finished, // Promise that resolves to records
};
}
export function spawnRequestWithNested(asyncLocalStorage, outerId, innerId) {
// Demonstrates nested run() and restoration of the outer context after inner finishes
const innerDone = makeDeferred();
const records = [];
const finished = asyncLocalStorage.run(outerId, async () => {
records.push(asyncLocalStorage.getStore()); // outer visible
// start a nested run synchronously inside the outer run
// nested run should temporarily replace the store for its callback
const nestedResult = asyncLocalStorage.run(innerId, () => {
// inside nested callback
records.push(asyncLocalStorage.getStore()); // should be innerId
// wait for external signal to finish nested part
return innerDone.promise.then(() => asyncLocalStorage.getStore());
});
// nestedResult is a promise that resolves after innerDone.resolve() is called
const innerObserved = await nestedResult;
records.push(innerObserved); // snapshot returned from nested callback
// After nested run completes, the outer store should be restored for the remainder
records.push(asyncLocalStorage.getStore()); // should be outerId again
return records;
});
return {
controllers: { completeInner: innerDone.resolve },
finished,
};
}
export function spawnRequestThatThrows(asyncLocalStorage, id) {
// Demonstrates that if callback throws, context is exited / restored and the exception propagates
const step = makeDeferred();
const records = [];
const finished = asyncLocalStorage.run(id, async () => {
records.push(asyncLocalStorage.getStore());
// wait for external signal, then throw
await step.promise;
throw new Error('boom-' + id);
});
return { controllers: { trigger: step.resolve }, finished, recordsPromise: Promise.resolve(records) };
}
// Small helper to demonstrate the pitfall of a shared mutable variable
export function runWithSharedVariable(shared, id, stepPromise) {
// This is not using AsyncLocalStorage. It mutates a shared object and performs async hops.
// The test will show this approach can interleave and leak values between concurrent requests.
return (async () => {
shared.value = id; // overwrite shared state
const seenBefore = shared.value;
await stepPromise; // yield to the event loop / other tasks
const seenAfter = shared.value; // may have been changed by another concurrent request
return [seenBefore, seenAfter];
})();
}
Read the full article: https://chriseugenerodriguez.com/blog/node-asynclocalstorage-run-request-context