JavaScript AsyncContext: Propagating Context Through Async Operations

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.