How to Use AsyncLocal in C# for Async State Flow

In modern asynchronous C# programming, managing ambient context across multiple threads can be challenging. This article explains what AsyncLocal<T> is, why traditional thread-local storage fails in asynchronous workflows, how AsyncLocal<T> leverages the .NET ExecutionContext to persist data across await boundaries, and the underlying copy-on-write mechanics that govern its behavior.

The Problem with ThreadLocal in Async Code

Before AsyncLocal<T>, developers relied on ThreadLocal<T> to store contextual data specific to the executing thread. However, asynchronous programming using async and await fundamentally breaks the assumption of single-thread execution:

  • When an await yields execution, the calling thread is returned to the ThreadPool.
  • When the awaited task completes, the continuation may resume on an entirely different thread.
  • Because ThreadLocal<T> is strictly tied to an individual managed thread ID, any state stored inside it is lost or swapped when execution resumes on a different thread.

What is AsyncLocal<T>?

Introduced in .NET 4.6, AsyncLocal<T> represents ambient data that is local to a specific asynchronous control flow rather than a specific physical thread.

private static readonly AsyncLocal<string> _correlationId = new AsyncLocal<string>();

Regardless of how many times execution yields or which thread pool threads are assigned to run the continuations, the data stored inside _correlationId.Value remains accessible throughout that logical path of execution.

How AsyncLocal Flows Across await Boundaries

The persistence of AsyncLocal<T> values is powered by the .NET ExecutionContext.

  1. Context Capture: When an asynchronous method begins or invokes an await expression, the runtime captures the current ExecutionContext. This context contains an internal dictionary holding references to all active AsyncLocal<T> values.
  2. Context Restoration: When the awaited operation completes and a thread becomes available to run the continuation, the runtime restores the captured ExecutionContext onto that thread before executing the remaining code.
  3. Thread Transparency: Because the context travels with the delegate representing the continuation, your code observes the same AsyncLocal<T> values even if execution resumes on a different thread.

Flow Mechanics: Downward Flow and Copy-on-Write

AsyncLocal<T> follows a "flow down, not up" model governed by copy-on-write semantics.

  • Parent to Child (Downward): A child asynchronous method inherits the AsyncLocal<T> values set by its caller.
  • Child to Parent (Upward Isolation): If a child method modifies an AsyncLocal<T>.Value, that mutation only affects the child's local context. The parent's context remains unchanged when execution returns to the parent.
public async Task ProcessAsync()
{
    _correlationId.Value = "Initial";
    
    await ModifyContextAsync();
    
    // Output is still "Initial" because child modifications do not flow upward.
    Console.WriteLine(_correlationId.Value); 
}

private async Task ModifyContextAsync()
{
    // The child starts with "Initial", but reassigning creates a fork.
    _correlationId.Value = "Modified";
    await Task.Yield();
}

Note on Reference Types: If the value stored in AsyncLocal<T> is a mutable reference type (such as a class or List<T>), mutating properties or items of that object will be visible across both parent and child, because both contexts hold references pointing to the same instance in memory. To avoid unexpected side effects, store immutable objects or value types in AsyncLocal<T>.

Suppressing Context Flow

In high-throughput scenarios, capturing and restoring the ExecutionContext carries a minor performance cost. If you spawn a background task that does not need ambient state, you can suppress context flow:

using (ExecutionContext.SuppressFlow())
{
    Task.Run(() => 
    {
        // _correlationId.Value will be null here
    });
}
// Context flow is automatically restored when leaving the using scope

Monitoring Changes with AsyncLocalValueChangedArgs

AsyncLocal<T> provides a constructor that accepts a callback delegate. This delegate triggers whenever the ambient value changes, whether due to direct assignment or because the context was restored:

private static readonly AsyncLocal<string> _traceId = new AsyncLocal<string>(args =>
{
    Console.WriteLine($"Previous: {args.PreviousValue}, Current: {args.CurrentValue}, Thread Context Changed: {args.ThreadContextChanged}");
});

This feature is particularly useful for profiling, cleanup, or synchronizing native tracking contexts.

Common Use Cases

  • Distributed Tracing: Storing Correlation IDs or Trace Identifiers across HTTP pipelines and background jobs.
  • Security Contexts: Propagating user identity, claims, or tenancy identifiers across asynchronous business logic layers.
  • Ambient Scopes: Managing database transactions or diagnostic activity scopes without passing context objects manually through every method signature.