Fundamentals 18 min read

How AsyncContext Enables Seamless Data Propagation Across JavaScript Async Calls

The article introduces the TC39 Async Context proposal, explains why asynchronous context is needed, shows a library implementation using run and log functions, describes the AsyncContext API with run, get, and wrap methods, and explores use cases such as trace propagation, task‑priority handling, and comparisons with thread‑local storage and Node.js AsyncLocalStorage.

Taobao Frontend Technology
Taobao Frontend Technology
Taobao Frontend Technology
How AsyncContext Enables Seamless Data Propagation Across JavaScript Async Calls

Background

Led by Alibaba TC39 representatives, the Async Context proposal became a TC39 Stage 1 proposal in early February 2023. Its goal is to define a way to pass data through JavaScript asynchronous tasks.

To illustrate why an async context is needed, imagine an npm library that provides

log

and

run

functions. Developers pass a callback and an id to

run

, which invokes the callback and allows the callback to call

log

to emit logs tagged with the id.

<code>// my-awesome-library
let currentId = undefined;
export function log(){
  if (currentId === undefined) throw new Error('must be inside a run call stack');
  console.log(`[${currentId}]`, ...arguments);
}
export function run&lt;T&gt;(id: string, cb: () => T){
  let prevId = currentId;
  try {
    currentId = id;
    return cb();
  } finally {
    currentId = prevId;
  }
}
</code>

Usage example:

<code>import { run, log } from 'my-awesome-library';
import { helper } from 'some-random-npm-library';

document.body.addEventListener('click', () => {
  const id = nextId();
  run(id, () => {
    log('starting');
    // assume helper calls doSomething
    helper(doSomething);
    log('done');
  });
});
function doSomething(){
  log("did something");
}
</code>

For each click, the logs appear as:

[id1] starting
[id1] did something
[id1] done

This demonstrates an id‑based mechanism that propagates through the synchronous call stack without requiring developers to manually pass or store the id, similar to how React Context passes parameters through component trees.

When asynchronous operations are introduced, the pattern breaks because the id is lost across async boundaries:

<code>document.body.addEventListener('click', () => {
  const id = new Uuid();
  run(id, async () => {
    log('starting');
    await helper(doSomething);
    // This log can no longer print the expected id
    log('done');
  });
});
function doSomething(){
  // Whether this log prints the expected id depends on whether helper awaited before calling doSomething
  log("did something");
}
</code>

The AsyncContext proposal solves this problem by allowing the id to be passed both through synchronous call stacks and asynchronous task chains.

<code>// my-awesome-library
const context = new AsyncContext();
export function log(){
  const currentId = context.get();
  if (currentId === undefined) throw new Error('must be inside a run call stack');
  console.log(`[${currentId}]`, ...arguments);
}
export function run&lt;T&gt;(id: string, cb: () => T){
  context.run(id, cb);
}
</code>

AsyncContext

AsyncContext is a storage that can propagate any JavaScript value through a chain of synchronous and asynchronous operations. It provides three core methods:

<code>class AsyncContext&lt;T&gt; {
  // Snapshot all AsyncContext values in the current execution context and return a function that restores the snapshot.
  static wrap&lt;R&gt;(fn: (...args: any[]) => R): (...args: any[]) => R;

  // Immediately execute fn while setting value as the current AsyncContext value; the value is snapshot for any async operations started inside fn.
  run&lt;R&gt;(value: T, fn: () => R): R;

  // Retrieve the current AsyncContext value.
  get(): T;
}
</code>
AsyncContext.prototype.run()

writes a value,

get()

reads it, and

wrap()

snapshots the whole context so it can be restored later. These three operations form the minimal interface for propagating values across async tasks.

<code>// simple task queue example
const loop = {
  queue: [],
  addTask: (fn) => { queue.push(AsyncContext.wrap(fn)); },
  run: () => { while (queue.length > 0) { const fn = queue.shift(); fn(); } }
};
const ctx = new AsyncContext();
ctx.run('1', () => {
  loop.addTask(() => { console.log('task:', ctx.get()); });
  setTimeout(() => { console.log(ctx.get()); }, 1000); // => 1
});
ctx.run('2', () => {
  setTimeout(() => { console.log(ctx.get()); }, 500); // => 2
});
console.log(ctx.get()); // => undefined
loop.run(); // => task: 1
</code>

Use Cases

Async Trace Propagation

APM tools like OpenTelemetry need to propagate trace data without requiring developers to modify business code. By storing trace information in an AsyncContext, the runtime can retrieve the current trace from the context at any point.

<code>// tracer.js
const context = new AsyncContext();
export function run(cb){
  const span = {
    parent: context.get(),
    startTime: Date.now(),
    traceId: randomUUID(),
    spanId: randomUUID()
  };
  context.run(span, cb);
}
export function end(){
  const span = context.get();
  span?.endTime = Date.now();
}
const originalFetch = globalThis.fetch;
globalThis.fetch = (...args) => {
  return run(() => originalFetch(...args).finally(() => end()));
};
</code>

Application code remains unchanged; the tracer automatically wraps user callbacks.

Async Task Attribute Propagation

Scheduling APIs can use AsyncContext to automatically carry task attributes such as priority, eliminating the need for developers to pass them manually.

<code>// simple scheduler using AsyncContext
const scheduler = {
  context: new AsyncContext(),
  postTask(task, options){ this.context.run({ priority: options.priority }, task); },
  currentTask(){ return this.context.get() ?? { priority: 'default' }; }
};
// user code
const res = await scheduler.postTask(task, { priority: 'background' });
async function task(){
  const resp = await fetch('/hello');
  const text = await resp.text();
  scheduler.currentTask(); // => { priority: 'background' }
  return doStuffs(text);
}
async function doStuffs(text){ return text; }
</code>

This addresses a challenge identified by the WICG Scheduling APIs group.

Prior Arts

Thread‑Local Variables

Thread‑local storage provides per‑thread state without global interference, useful for re‑entrancy and request‑scoped data. Example in C++ shows each thread having its own

rage

counter.

<code>#include <iostream>
#include <thread>
#include <mutex>
thread_local unsigned int rage = 1;
std::mutex cout_mutex;
void increase_rage(const std::string& thread_name){
  ++rage;
  std::lock_guard<std::mutex> lock(cout_mutex);
  std::cout << "Rage counter for " << thread_name << ": " << rage << '\n';
}
int main(){
  std::thread a(increase_rage, "a"), b(increase_rage, "b");
  a.join(); b.join();
  {
    std::lock_guard<std::mutex> lock(cout_mutex);
    std::cout << "Rage counter for main: " << rage << '\n';
  }
  return 0;
}
</code>

Thread‑local storage is also used to keep request‑level information in server models.

AsyncLocalStorage

Node.js provides AsyncLocalStorage as a single‑threaded analogue of thread‑local storage. AsyncContext builds on top of it.

<code>class AsyncLocalStorage&lt;T&gt; {
  constructor();
  run&lt;R&gt;(store: T, callback: (...args: any[]) => R, ...args: any[]): R;
  getStore(): T;
}
class AsyncResource {
  constructor();
  runInAsyncScope&lt;R&gt;(fn: (...args: any[]) => R, thisArg, ...args: any[]): R;
}
</code>

The AsyncContext API is expected to remain stable as the proposal progresses.

Noslate & WinterCG

Noslate Aworker, a member of the Web‑Interoperable Runtimes CG, is implementing a subset of AsyncLocalStorage that aligns with the future AsyncContext API, providing an early‑adoption path for runtimes like Cloudflare Workers and Deno.

More ECMAScript Proposals

The JavaScript Chinese Interest Group (JSCIG) invites contributions to ECMAScript discussions on GitHub.

References

Async Context proposal: https://github.com/tc39/proposal-async-context

React Context: https://reactjs.org/docs/context.html

OpenTelemetry: https://opentelemetry.io/

Scheduling APIs: https://github.com/WICG/scheduling-apis

Challenge in unified task model: https://github.com/WICG/scheduling-apis/blob/main/misc/userspace-task-models.md#challenges-in-creating-a-unified-task-model

Thread‑local variables: https://zh.wikipedia.org/wiki/%E7%BA%BF%E7%A8%8B%E5%B1%80%E9%83%A8%E5%AD%98%E5%82%A8

errno: http://man7.org/linux/man-pages/man3/errno.3.html

Noslate Aworker: https://noslate.midwayjs.org/docs/noslate_workers/intro

Web‑Interoperable Runtimes CG: https://wintercg.org/

AsyncLocalStorage subset: https://github.com/wintercg/proposal-common-minimum-api/blob/main/asynclocalstorage.md

javascriptweb developmentAsyncLocalStorageTC39AsyncContext
Taobao Frontend Technology
Written by

Taobao Frontend Technology

The frontend landscape is constantly evolving, with rapid innovations across familiar languages. Like us, your understanding of the frontend is continually refreshed. Join us on Taobao, a vibrant, all‑encompassing platform, to uncover limitless potential.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.