Ambient State in .NET 11 Async Code

An asynchronous method can look almost empty and still carry a surprising amount of state. A correlation ID, the current tracing activity and a logging scope can all follow execution through a method without appearing in its parameters. It also means a benchmark containing a few awaits can run under different conditions from the same code inside a real service. .NET 11 makes this an interesting area to investigate. Its runtime includes an optimisation for continuations that have no execution context to restore. To understand when that helps, we need to look at what our applications carry across asynchronous boundaries.
What changes in .NET 11
Microsofts .NET 11 runtime notes describe a path that avoids execution context capture and restoration when there's nothing to restore. The notes identify benefits for Task, Task<T>, ValueTask and ValueTask<T>, as well as the Runtime Async implementation. Runtime Async is the separate, opt-in preview that moves asynchronous suspension and resumption into the runtime. You don't need to opt your application into that compilation model to investigate the ordinary task continuation improvements. The qualification is important, an optimisation for an empty context doesnt imply the same saving when ambient values are present. Existing context flow semantics still have to be preserved. That gives us a useful experiment. How does an otherwise identical asynchronous operation behave with no application owned ambient state, with stable ambient state, and with values that change repeatedly?
What execution context carries
AsyncLocal<T> associates a value with an asynchronous flow of execution. A request identifier can therefore remain available after an await, even if execution continues on another thread:
using System.Threading;
static class RequestContext
{
public static readonly AsyncLocal<string?> CorrelationId = new();
}
// Inside an asynchronous method:
RequestContext.CorrelationId.Value = "request-42";
await Task.Delay(10).ConfigureAwait(false);
Console.WriteLine(RequestContext.CorrelationId.Value);
// request-42
ExecutionContext provides the machinery for flowing this state. The official explanation of execution and synchronisation contexts covers how it follows asynchronous execution. There are several costs worth separating. Reading an existing value, assigning a different value, capturing context for later execution and restoring context are different operations. Change notifications introduce another dimension. It would be misleading to describe all of this as copying a dictionary at every await. CoreCLR's execution context is immutable, and its implementation can reuse the existing instance. Setting a different AsyncLocal value can create a new context representation. Objects referenced by those values aren't recursively cloned. That last point also affects correctness. Put a mutable object in an AsyncLocal and child operations can hold references to the same object. Context flow doesn't provide isolation for mutations to that object.
ConfigureAwait(false) still flows ambient values
The example deliberately uses ConfigureAwait(false). The correlation ID survives it.
ConfigureAwait(false) changes whether the continuation targets a captured synchronisation context or non default task scheduler. It doesnt disable execution context flow. Microsoft's ConfigureAwait FAQ addresses this distinction explicitly.
These mechanisms answer different questions:
| Mechanism | Responsibility |
|---|---|
SynchronizationContext and task scheduling |
Where a continuation should execute |
ExecutionContext |
Which ambient state accompanies execution |
AsyncLocal<T> |
A value associated with the asynchronous flow |
Adding ConfigureAwait(false) throughout a library wont remove its callers' correlation IDs or logging scopes. Nor should it: callers may depend on those values remaining available.
Ambient state can arrive through your dependencies
Searching a codebase for new AsyncLocal only finds the values that codebase creates directly. The standard LoggerExternalScopeProvider keeps its current scope in an AsyncLocal. An active scope can therefore introduce ambient state even if your application never declares an AsyncLocal field. The exact behaviour of ILogger.BeginScope depends on the configured providers and scope support. Similarly, Activity.Current is backed by an AsyncLocal<Activity?>. An active tracing activity participates in context flow. Whether ActivitySource.StartActivity creates an activity depends on listeners and sampling, so a benchmark must verify that it actually created one. Neither observation tells us to remove scopes or tracing. Both explain why an isolated benchmark needs a second run under representative instrumentation.
A benchmark that separates the cases
The following harness compares six scenarios. It deliberately uses ForceYielding so the awaits take an asynchronous continuation path, even though the underlying task is already complete. A normal await of Task.CompletedTask would mostly measure synchronous completion. Create a console project, install a BenchmarkDotNet version that supports your installed SDKs, and add Microsoft.Extensions.Logging.Abstractions. Set the project to target both frameworks:
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0;net11.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
Use this as Program.cs:
using System.Diagnostics;
using System.Threading;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.Extensions.Logging;
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
public enum AmbientScenario
{
None,
StableValue,
ChangingValue,
NotifyingValue,
LoggingScope,
Activity
}
[MemoryDiagnoser]
public class AmbientStateBenchmarks
{
private const int Hops = 128;
private static readonly AsyncLocal<string?> Ambient = new();
private static readonly AsyncLocal<string?> Notifying =
new(static _ => { });
private readonly LoggerExternalScopeProvider scopes = new();
[ParamsAllValues]
public AmbientScenario Scenario { get; set; }
[Benchmark]
public async Task Run()
{
string? previousAmbient = Ambient.Value;
string? previousNotifying = Notifying.Value;
IDisposable? scope = null;
Activity? activity = null;
try
{
switch (Scenario)
{
case AmbientScenario.StableValue:
case AmbientScenario.ChangingValue:
Ambient.Value = "initial";
break;
case AmbientScenario.NotifyingValue:
Notifying.Value = "initial";
break;
case AmbientScenario.LoggingScope:
scope = scopes.Push("request-42");
break;
case AmbientScenario.Activity:
activity = new Activity("benchmark").Start();
break;
}
for (int i = 0; i < Hops; i++)
{
if (Scenario == AmbientScenario.ChangingValue)
{
Ambient.Value = (i & 1) == 0 ? "even" : "odd";
}
await Task.CompletedTask.ConfigureAwait(
ConfigureAwaitOptions.ForceYielding);
}
}
finally
{
activity?.Dispose();
scope?.Dispose();
Ambient.Value = previousAmbient;
Notifying.Value = previousNotifying;
}
}
}
Run each framework separately in Release mode, without a debugger:
dotnet run -c Release -f net10.0 -- --filter '*AmbientStateBenchmarks*'
dotnet run -c Release -f net11.0 -- --filter '*AmbientStateBenchmarks*'
Both runtimes and their targeting packs must be installed. Keep package versions, machine settings and environment variables identical. Save the SDK and runtime versions with the results. The benchmark leaves application level Runtime Async opt-in disabled. If you later test that preview, treat it as a separate build configuration and record it alongside the measurements.
What this experiment can tell us
None provides a baseline without ambient values introduced by this harness. It doesn't prove that the benchmark infrastructure or environment has an entirely empty execution context. Profiling is needed before attributing a timing difference to a particular runtime fast path. StableValue sets one value around the whole loop. ChangingValue also writes a different value before every suspension. Their difference includes the work of those assignments; it cannot be labelled purely as context-restoration overhead. Alternating string literals avoids allocating new strings for each write. NotifyingValue installs a no-op change handler. This exercises notification machinery without adding application callback work. A production handler could add more work of its own.
LoggingScope directly exercises the standard scope provider. It doesnt create log events, format messages or write to a sink. Activity creates a real current activity directly, without requiring a listener, and doesn't configure an exporter. Their setup and disposal are included once per batch of 128 hops. The measurements therefore include scheduling, async machinery and scenario setup. They arent an isolated measurement of a single internal function. Dividing the elapsed time by 128 gives an approximate amortised cost per hop, including that shared setup.
Follow the microbenchmark with an application benchmark
Forced yields exaggerate the frequency of continuation scheduling. Real workloads combine synchronous completions, network waits, cache hits and CPU work. The balance determines whether a small continuation saving becomes visible. After running the harness, repeat a representative operation with your actual logging providers and tracing configuration. Compare throughput, CPU consumption, allocations and tail latency under the same offered load. Verify that correlation still works and spans retain the expected parents. Then inspect where ambient state changes. One scope around a request and a new scope around every item in a large batch are different workloads. A correlation value written once and a context object replaced at every layer are different workloads too. Avoid treating removal of one AsyncLocal as a guarantee of an empty context. A tracing activity or another library's scope may still be present. Likewise, the presence of an ambient value doesn't cancel every other improvement in .NET 11.
Keep ambient state deliberate
For business data that a method requires to do its job, explicit parameters often make dependencies easier to understand. A tenant identifier used to select data, for example, deserves a visible place in the operations contract. Correlation metadata can still benefit from ambient propagation. Where ambient state is useful, prefer a clear lifetime and avoid repeatedly replacing it without a reason. Restore values and dispose scopes reliably. Keep any context change callbacks small. ExecutionContext.SuppressFlow is an advanced API that changes propagation behaviour. Introducing it as a general performance shortcut can lose metadata that downstream code expects. Any deliberate suppression boundary needs its own correctness checks. .NET 11's optimisation gives us a reason to examine the context surrounding our asynchronous code. Benchmark the operation with the state it actually carries in production, then use those measurements to decide whether changing that state is worthwhile.
About the author
- Senior Software Engineer specialising in cloud architecture, distributed systems, and modern .NET development, with over two decades of experience designing and delivering enterprise platforms in financial, insurance, and high-scale commercial environments. My focus is on building systems that are reliable, scalable, and maintainable over the long term. I’ve led modernisation initiatives moving legacy platforms to cloud-native Azure architectures, designed high-throughput streaming solutions to eliminate performance bottlenecks, and implemented secure microservices environments using container-based deployment models and event-driven integration patterns. From an architecture perspective, I have strong practical experience applying approaches such as Vertical Slice Architecture, Domain-Driven Design, Clean Architecture, and Hexagonal Architecture. I’m particularly interested in modular system design that balances delivery speed with long-term sustainability, and I enjoy solving complex problems involving distributed workflows, performance optimisation, and system reliability. I enjoy mentoring engineers, contributing to architectural decisions, and helping teams simplify complex systems into clear, maintainable designs. I’m always open to connecting with other engineers, architects, and technology leaders working on modern cloud and distributed system challenges.
Comments
Join the discussionThe distinction between where a continuation runs and which ambient state travels with it is the part that sticks. The reminder that a logging scope or a tracing activity can introduce AsyncLocal state through a dependency is especially useful, since searching a codebase for new AsyncLocal would miss it entirely. The six scenario benchmark also looks like a careful way to separate the cost of a stable value from the cost of changing it on every suspension.





