The TC39 AsyncContext proposal (Stage 3) solves a longstanding problem in JavaScript: how to propagate contextual information — request IDs, user sessions, performance timers — through asynchronous call chains without threading it manually through every function parameter. AsyncContext.Variable works like a thread-local variable in other languages, automatically propagating its value through await, Promise.then(), setTimeout, and event listeners.
Problem: JavaScript async functions using await lose contextual data — user ID, request trace ID, or WordPress nonce — when control crosses async boundaries, because the execution context is not propagated through Promise chains.
Solution: Use the TC39 AsyncContext proposal (stage 2, polyfillable via async-context package) to attach a context object to an async execution: const ctx = new AsyncContext.Variable() stores a value that is automatically propagated through all await calls, Promise.all() chains, and event listeners within the same async task.
The examples below show basic AsyncContext usage, propagating a request ID through nested async operations, using it for performance tracing in a WordPress admin AJAX handler, and the snapshot API for cross-context transfer.
// ── 1. Basic AsyncContext.Variable ────────────────────────────────────────
const requestId = new AsyncContext.Variable( {
name: 'requestId',
defaultValue: 'unknown',
} );
async function handleRequest( id ) {
// Set the context for this async call chain
return requestId.run( id, async () => {
await doWork();
} );
}
async function doWork() {
console.log( 'Request ID:', requestId.get() ); // reads the current context value
await fetch( '/api/data' );
// Still works after await — context propagates through async boundaries
console.log( 'Request ID after await:', requestId.get() );
}
// ── 2. Propagate context through event listeners ─────────────────────────
const traceId = new AsyncContext.Variable( { name: 'traceId', defaultValue: null } );
button.addEventListener( 'click', () => {
traceId.run( crypto.randomUUID(), () => {
// All async work triggered from this listener carries the same traceId
submitForm();
} );
} );
async function submitForm() {
const id = traceId.get(); // available even after multiple awaits
const resp = await fetch( '/wp-admin/admin-ajax.php', {
method: 'POST',
headers: { 'X-Trace-Id': id },
body: new FormData( document.querySelector( 'form' ) ),
} );
await processResponse( resp );
}
async function processResponse( resp ) {
const id = traceId.get(); // still propagated
const data = await resp.json();
console.log( `[${id}] Response:`, data );
}
// ── 3. Performance tracing ────────────────────────────────────────────────
const perfCtx = new AsyncContext.Variable( { name: 'perfCtx', defaultValue: null } );
async function withTiming( name, fn ) {
const ctx = { name, start: performance.now(), spans: [] };
return perfCtx.run( ctx, async () => {
const result = await fn();
const elapsed = performance.now() - ctx.start;
console.log( `${name}: ${elapsed.toFixed(2)}ms`, ctx.spans );
return result;
} );
}
function span( label ) {
const ctx = perfCtx.get();
if ( ctx ) ctx.spans.push( { label, t: performance.now() - ctx.start } );
}
// Usage:
await withTiming( 'loadDashboard', async () => {
span( 'fetch-started' );
const data = await fetch( '/wp-json/my-plugin/v1/dashboard' ).then( r => r.json() );
span( 'fetch-done' );
renderDashboard( data );
span( 'render-done' );
} );
// ── 4. Snapshot: transfer context to a different async chain ─────────────
const snapshot = AsyncContext.Snapshot.wrap( () => {
// This callback captures the current context at wrap() time
// and restores it whenever the returned function is called
console.log( 'Context value:', requestId.get() );
} );
// Later, in a completely different async context:
setTimeout( snapshot, 1000 ); // logs the original requestId, not the new context
NOTE: AsyncContext is Stage 3 but not yet shipped in any browser or Node.js natively — use the async-context-polyfill package for experimentation; the polyfill works by patching Promise, setTimeout, and queueMicrotask, so it must be loaded before any other async code in the application.