async-browser-context gives the AsyncLocalStorage class of Node.js and the TC39 AsyncContext API to browsers. A value that you set for one operation stays available in all the asynchronous code of that operation. This includes the code after await, then callbacks, timers, event listeners and generators.
import { AsyncLocalStorage } from "async-browser-context";
const request = new AsyncLocalStorage<{ id : string }>();
await request.run({ id : "r-1" }, async () => {
const response = await fetch("/data");
log(`loaded ${response.status}`);
});
function log(message : string) : void {
console.log(`[${request.getStore()?.id ?? "none"}] ${message}`); // [r-1] loaded 200
}The documentation website has guides, the API reference and interactive diagrams: https://mark1russell7.github.io/AsyncBrowserContext/. Its context debugger starts code with the real library and shows each step. In the playground, you can start your own code.
Browsers do not have AsyncLocalStorage. A native await does not use code that a library can change, so a library alone cannot keep a value through await. This library has two parts:
- A runtime. It keeps the current context. It patches
Promise.prototype.then, the timers, the event listeners, the observers and other callback APIs. Thus, each callback gets the context of the code that registered it. - A Babel preset and a Vite plugin. They change each async function into a generator that the runtime operates. Before each step of the function, the runtime sets the context of the function. After the step, the runtime sets the previous context again.
On Node.js, the package uses the native AsyncLocalStorage of node:async_hooks. No transform and no patch is necessary on Node.js.
| Tool | Where it operates | Context after a native await |
API |
|---|---|---|---|
AsyncLocalStorage of Node.js |
Node.js and other server runtimes | Yes | AsyncLocalStorage |
TC39 AsyncContext proposal |
No browser has it at this time (Stage 2) | Yes, when browsers have it | AsyncContext.Variable, AsyncContext.Snapshot |
| zone.js | Browsers | No. A compiler must change async functions before zone.js can see them. | Zone |
async-browser-context |
Browsers and Node.js | Yes, through the Babel preset or the Vite plugin | AsyncLocalStorage and AsyncContext |
Thus, code that uses AsyncLocalStorage on the server can use the same API in the browser. The AsyncContext API of the library is the API of the proposal. When browsers have AsyncContext, a change to the native API is small: change the import.
npm install async-browser-contextThe package needs Node.js 22.18 or later, or 24.11 or later, for the build tools. The runtime operates in the current versions of Chrome, Edge, Firefox and Safari.
CAUTION: Transform all the code that awaits in a context, also the code in
node_modules. In code that the transform does not change, the browser runtime cannot set the context afterawait. That code gets the root context:getStore()givesundefined. It does not get the context of a different operation at any time.
-
Add the plugin to
vite.config.ts:import { defineConfig } from "vite"; import { asyncContext } from "async-browser-context/vite"; export default defineConfig({ plugins : [asyncContext()], });
-
Build or start the application as usual.
The plugin transforms the modules of the application and of its dependencies, also in the dev server. It operates after the other plugins, so it gets JavaScript after TypeScript and JSX are compiled. The plugin does not transform the modules of server-side rendering, because Node.js has the native AsyncLocalStorage.
| Option | Default | Function |
|---|---|---|
include |
All JavaScript and TypeScript modules | A regular expression or a function that selects the modules to transform. |
exclude |
None | A regular expression or a function that selects the modules not to transform. |
runtime |
async-browser-context/runtime |
The module that the transformed code imports. |
ssr |
false |
Set true to also transform the modules of server-side rendering, for example for Vitest in Node.js. |
-
Add the preset to the Babel configuration. Put it last in the
presetslist, because Babel uses the presets in reverse order:{ "presets": ["@babel/preset-env", "async-browser-context/babel-preset"] } -
Make sure that Babel transforms the dependencies too. With webpack, do not exclude
node_modulesfrombabel-loader.
The preset operates with Babel 7.22 and later, and with Babel 8.
Do not set up a transform. The node export condition selects the Node.js entry, which uses the native AsyncLocalStorage.
The package gives two equivalent APIs.
AsyncLocalStorage, the class of Node.js:
import { AsyncLocalStorage } from "async-browser-context";
const storage = new AsyncLocalStorage<string>({ defaultValue : "none" });
storage.run("value", () => storage.getStore()); // "value"
storage.getStore(); // "none"The class has getStore(), run(), exit(), enterWith(), disable(), the static bind() and snapshot(), and the name and defaultValue options of Node.js 24.
AsyncContext.Variable and AsyncContext.Snapshot, the API of the TC39 proposal:
import { AsyncContext } from "async-browser-context";
const userId = new AsyncContext.Variable<string>({ name : "userId" });
const snapshot = userId.run("u-1", () => new AsyncContext.Snapshot());
button.addEventListener("click", AsyncContext.Snapshot.wrap(() => {
console.log(userId.get());
}));
snapshot.run(() => userId.get()); // "u-1"The package also exports Variable, Snapshot, and the other names AsyncVariable and AsyncSnapshot.
| Import | Function |
|---|---|
async-browser-context |
The API. The node export condition selects the Node.js entry. Other environments get the browser entry. |
async-browser-context/browser |
The browser entry, also on Node.js. |
async-browser-context/runtime |
The runtime functions that the transformed code imports. |
async-browser-context/browser/runtime |
The browser runtime functions, also on Node.js. Use it as the runtime option together with async-browser-context/browser. |
async-browser-context/opentelemetry |
The OpenTelemetry context manager AsyncContextManager. It needs @opentelemetry/api. |
async-browser-context/vite |
The Vite plugin. |
async-browser-context/babel-preset |
The Babel preset. |
The web tracing of OpenTelemetry keeps the active span with a context manager. The ZoneContextManager of OpenTelemetry uses zone.js, and zone.js does not see a native await. AsyncContextManager uses AsyncLocalStorage of this library, so the active span stays correct after each await in transformed code.
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
import { AsyncContextManager } from "async-browser-context/opentelemetry";
const provider = new WebTracerProvider();
provider.register({ contextManager : new AsyncContextManager() });On Node.js, AsyncContextManager uses the native AsyncLocalStorage. Do not use it together with zone.js.
The tests examine each rule on the browser runtime, on the Node.js runtime and in Chromium, Firefox and WebKit.
| Rule | The library keeps this rule |
|---|---|
| C1 | Outside a context, get() gives the default value. Between tasks, the current context is the root context. |
| C2 | While run(value, fn) starts fn, get() gives value. After fn, the previous context is current again. |
| C3 | An async function keeps the context of its call after each await, also in the same expression and after a rejection. |
| C4 | A then, catch or finally callback gets the context of the then, catch or finally call. |
| C5 | A generator body gets the context of the call that made the generator. After each step, the context of the caller is current again. |
| C6 | A timer callback gets the context of the call that scheduled it. |
| C7 | Code that the transform does not change cannot get the context of a different operation. At most, it gets no context after its own native await. |
| C8 | snapshot.run() and Snapshot.wrap() start functions in the recorded context. |
| C9 | Only code that has a Variable can read its values. The library puts no values on globalThis. |
| C10 | Two copies of the library on one page use one context store. |
| C11 | The library does not replace the Promise constructor. Promise subclasses operate as without the library. |
| C12 | The library does not keep promises or values in memory after the program has no reference to them. |
| C13 | An event listener gets the context of the code that dispatches the event. When the browser dispatches the event, the listener gets the context of the addEventListener() call or of the assignment to the on... property. |
CAUTION: Import
async-browser-contextbefore all other modules in the entry file of the application. The library patches global functions when it loads. Code that keeps a reference tosetTimeoutor to another patched function before the library loads does not get the context.
| Group | APIs |
|---|---|
| Promises | Promise.prototype.then, thus also catch, finally and the combinators |
| Timers | setTimeout, setInterval, setImmediate, queueMicrotask, requestAnimationFrame, requestIdleCallback, scheduler.postTask, requestVideoFrameCallback, XRSession.requestAnimationFrame |
| Events | addEventListener, removeEventListener, all on... properties, MediaQueryList.addListener |
| Observers | MutationObserver, ResizeObserver, IntersectionObserver, PerformanceObserver, ReportingObserver, PressureObserver, FinalizationRegistry |
| Streams | The methods of the underlying objects of ReadableStream, WritableStream and TransformStream |
| Callback APIs | navigator.locks.request, HTMLCanvasElement.toBlob, Array.fromAsync, geolocation, DataTransferItem.getAsString, the APIs of file system entries, decodeAudioData, MediaSession.setActionHandler, Notification.requestPermission, the callbacks of RTCPeerConnection, startViewTransition, NavigateEvent.intercept |
The patches keep the names, the lengths and the source text of the native functions. Function.prototype.toString gives the source text of the original function.
In transformed code, each step gets the context of its own operation. Code without the transform cannot get the context of a different operation (rule C7). At most, that code gets no context after its own native await. Where your code meets that code, one function of the library keeps the context:
| The other code | What to do |
|---|---|
| Gives a promise, and your code awaits it | Nothing. After the await, your code gets its context again. |
Starts your callback synchronously, or from a timer, an observer or a promise that it registers before its first await |
Nothing. The patched APIs keep the context. Event listeners follow rule C13. |
Starts your callback after its own native await |
AsyncLocalStorage.bind(callback) or AsyncContext.Snapshot.wrap(callback) |
| Keeps your callback in a list and starts it later from other code | AsyncLocalStorage.bind(callback) when you give the callback |
Reads the context itself after its own native await |
Give the values as arguments, or transform the code. |
| Operates in a worker or an iframe | Send the values in the message, and use run() on the other side. |
untransformedLibrary.onDone(AsyncLocalStorage.bind((result) => {
log(requestId.getStore(), result); // the context of the bind() use
}));The Vite plugin transforms the dependencies in node_modules by default. Thus, these boundaries are usually only scripts from other servers, browser extensions and code from eval() or new Function(). The tests in test/rules/c07-boundaries.test.ts examine each row. The boundaries page of the website gives the details.
Do not use the library together with zone.js or with another library that patches Promise.prototype.then. The two libraries patch the same functions with different rules. For OpenTelemetry, use AsyncContextManager in place of the ZoneContextManager.
- Generator functions. A transformed generator function is an ordinary function that gives a generator.
fn.prototypeandinstanceof fndo not operate as for a native generator function. The generator objects operate as native generator objects. - Stack traces. In transformed code, a stack trace shows the generator and the
coroutinefunction of the runtime.
These results are from pnpm bench: the mean time of each scenario, on Node.js 25.2.1 and Windows 11. Each case operates in its own process. The values are the median of 5 rounds.
| Scenario | Plain JavaScript | Library | Factor |
|---|---|---|---|
| 10,000 awaits in one async function | 0.26 ms | 0.40 ms | 1.56 |
| 10,000 calls of an async function | 0.61 ms | 1.22 ms | 1.99 |
A chain of 10,000 then calls |
0.19 ms | 0.33 ms | 1.77 |
| 1,000 tasks with 10 awaits each | 0.39 ms | 0.68 ms | 1.75 |
| 1,000 request handlers in 1,000 contexts | 0.31 ms | 0.60 ms | 1.90 |
100,000 reads with get(), 5 contexts deep |
0.105 ms | 0.39 ms | 3.74 |
One read with get() takes approximately 4 ns. The documentation website shows the full results.
| Command | Function |
|---|---|
pnpm test |
All test projects: unit tests, the rules on the browser runtime and on the Node.js runtime, memory tests, and the browser tests |
pnpm test:node |
The tests in Node.js only |
pnpm test:browser |
The tests in Chromium, Firefox and WebKit |
pnpm smoke |
Packs the package, installs it in a new Vite application, and examines the application in Chromium |
pnpm mutation |
Mutation testing with Stryker |
pnpm bench |
The benchmarks |
pnpm lint:ste |
The STE linter of the documentation and the TSDoc comments |
Set BROWSERS=chromium,firefox,webkit to select the browsers. Playwright WebKit does not start on all Windows computers, so the default on Windows is Chromium and Firefox. docs/testing.md tells more.
The documentation follows ASD-STE100 Simplified Technical English. pnpm lint:ste examines it.
MIT
