DEV Community

Cover image for How AsyncLocalStorage.run() isolates request context across concurrent async calls
Chris Rodriguez
Chris Rodriguez

Posted on Originally published at chriseugenerodriguez.com

How AsyncLocalStorage.run() isolates request context across concurrent async calls

Implementation overview

Use a single AsyncLocalStorage instance and deterministic fixtures that exercise overlapping requests without timers. Each request calls asyncLocalStorage.run(store, callback) synchronously; inside the callback you create controlled awaits (manually resolved promises) and record asyncLocalStorage.getStore() at several checkpoints: immediately, after the first await, and after the second await. Run two requests concurrently and interleave the resolutions to prove each request keeps its own store even when their continuations mix.

Expected behavior

  • Each run(callback) establishes a store visible synchronously inside the callback and from any asynchronous work created there. Two overlapping runs each observe only their own store at every checkpoint, so logs or per-request values won’t be accidentally mixed.
  • Nested run(outerStore, ...) that starts an inner run(innerStore, ...) temporarily replaces the store for the inner callback; when the inner callback completes, the outer store is restored for the remainder of the outer work.
  • asyncLocalStorage.getStore() returns undefined outside any active run() and after a run() callback throws; the context does not leak.

Limitations

  • AsyncLocalStorage is not a security boundary. Any code already running in the process that can read the store has access to its values; do not rely on it to protect secrets.
  • The store doesn’t automatically cross process or service boundaries. If you dispatch work to another process, thread, or service, you must explicitly propagate the ID.
  • The API attaches values to the async chain created inside the run() callback. If your code creates asynchronous work elsewhere (not inside run()), that work will not automatically see the store.

Why use a deterministic fixture

Using manually resolved promises in the fixture removes timing flakiness: you don’t rely on slow timers or specific scheduler timings to provoke interleaving. The test explicitly drives the interleaving you want to prove, so you can demonstrate the broken shared-variable model and the correct AsyncLocalStorage behavior reliably.

Server will insert the verified implementation 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];
  })();
}
Enter fullscreen mode Exit fullscreen mode

Read the full article: https://chriseugenerodriguez.com/blog/node-asynclocalstorage-run-request-context

Top comments (0)