Mini-tutorial: Use AsyncLocalStorage.run to isolate request context across interleaved async work

Mini-tutorial: Use AsyncLocalStorage.run to isolate request context across interleaved async work

calendar_today ago • schedule3 min read
— Originally published at chriseugenerodriguez.com

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

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

The Sovereign Vault — A Comprehensive Guide to Protocol-Driven AI

Ken W. Algerverified - Jun 4

Stop Treating EDA as Just Pub/Sub. Here’s How to Handle Request/Reply

Stephen - Oct 13

Attention-Free Score: How Domain Reports Show Which Pages Need Work

ApogeeWatcherverified - Sep 1

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9

The Context Window Isn't Memory. It's the CPU Cache of AI.

Ken W. Algerverified - Jul 22
chevron_left
219 Points • 8 Badges
Santa Monica, CA • chriseugenerodriguez.com
5Posts
0Comments
1Connections
10 years fe

Related Jobs

View all jobs →

Commenters (This Week)

1 comment
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!