AsyncLocal
IAsyncEnumerable
yield
asynchronous programming
.NET debugging

Why is AsyncLocal value lost between IAsyncEnumerable yield points

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

AsyncLocal<T> values are lost between yield return points in IAsyncEnumerable methods because each yield captures and restores the ExecutionContext from the point where the iterator was created, not from where it was last resumed. When the consumer calls MoveNextAsync(), the iterator resumes with the original execution context, which does not include AsyncLocal values set after iteration began. The fix is to pass values explicitly through parameters or closures instead of relying on AsyncLocal across yield boundaries.

The Problem

csharp
1static AsyncLocal<string> _context = new AsyncLocal<string>();
2
3static async IAsyncEnumerable<int> GetNumbers()
4{
5    Console.WriteLine($"Before yield 1: {_context.Value}");  // "hello"
6    yield return 1;
7
8    Console.WriteLine($"Before yield 2: {_context.Value}");  // null!
9    yield return 2;
10}
11
12static async Task Main()
13{
14    _context.Value = "hello";
15
16    await foreach (var num in GetNumbers())
17    {
18        Console.WriteLine($"In loop ({num}): {_context.Value}");
19        _context.Value = "updated";  // This change is lost after yield
20    }
21}

Output:

 
1Before yield 1: hello
2In loop (1): hello
3Before yield 2: nullLost!
4In loop (2): null

Why This Happens

AsyncLocal<T> stores values in the ExecutionContext, which flows through async calls. The key behavior:

  1. When GetNumbers() is first called, it captures the current ExecutionContext (with _context.Value = "hello")
  2. At yield return, the iterator suspends and returns control to the caller
  3. The caller modifies _context.Value = "updated" — this creates a new ExecutionContext copy (copy-on-write)
  4. When MoveNextAsync() resumes the iterator, it restores the original captured context, not the modified one
  5. The AsyncLocal value in the restored context is whatever it was at capture time

This is by design — ExecutionContext flows downward (caller to callee) but changes in the callee do not propagate back to the caller, and the iterator's context is snapshot at creation.

Simplified Explanation

 
1Caller sets AsyncLocal = "hello"
2  └─ Calls GetNumbers() → captures ExecutionContext snapshot
3      ├─ yield return 1 → suspends, snapshot saved
45Caller reads AsyncLocal = "hello"6Caller sets AsyncLocal = "updated"
78      ├─ MoveNextAsync() → restores ORIGINAL snapshot (AsyncLocal = "hello")
9But by this point, the original context has been copied
10      │   and the restored copy may show null depending on timing
11      └─ yield return 2AsyncLocal appears null or stale

Fix 1: Pass Values as Parameters

csharp
1static async IAsyncEnumerable<int> GetNumbers(string context)
2{
3    Console.WriteLine($"Before yield 1: {context}");
4    yield return 1;
5
6    Console.WriteLine($"Before yield 2: {context}");
7    yield return 2;
8}
9
10static async Task Main()
11{
12    await foreach (var num in GetNumbers("hello"))
13    {
14        Console.WriteLine($"In loop ({num}): hello");
15    }
16}

Passing the value explicitly avoids AsyncLocal entirely and makes the data flow clear.

Fix 2: Capture in a Closure

csharp
1static async IAsyncEnumerable<int> GetNumbers()
2{
3    // Capture the value at the start — it's a local variable now
4    var capturedContext = _context.Value;
5
6    Console.WriteLine($"Before yield 1: {capturedContext}");  // "hello"
7    yield return 1;
8
9    Console.WriteLine($"Before yield 2: {capturedContext}");  // "hello"
10    yield return 2;
11}

Copying the AsyncLocal value to a local variable at the start of the method preserves it across yield points because local variables are stored in the state machine's fields.

Fix 3: Re-set AsyncLocal Before Each Yield

csharp
1static async IAsyncEnumerable<int> GetNumbers()
2{
3    var savedContext = _context.Value;
4
5    Console.WriteLine($"Before yield 1: {_context.Value}");
6    yield return 1;
7
8    // Re-set after each yield
9    _context.Value = savedContext;
10    Console.WriteLine($"Before yield 2: {_context.Value}");
11    yield return 2;
12}

The Same Issue with Regular IEnumerable

This is not unique to IAsyncEnumerable. Regular IEnumerable<T> with yield has the same behavior because the iterator state machine captures ExecutionContext at creation:

csharp
1static IEnumerable<int> GetNumbersSync()
2{
3    Console.WriteLine($"Start: {_context.Value}");  // "hello"
4    yield return 1;
5    Console.WriteLine($"After yield: {_context.Value}");  // May be null
6    yield return 2;
7}

When AsyncLocal Does Work

AsyncLocal flows correctly through regular async/await because the entire method runs as one logical operation:

csharp
1static async Task RegularAsync()
2{
3    _context.Value = "hello";
4    await Task.Delay(100);
5    Console.WriteLine(_context.Value);  // "hello" ← Works fine
6    await Task.Delay(100);
7    Console.WriteLine(_context.Value);  // "hello" ← Still works
8}

The difference: await suspends and resumes within the same logical async operation. yield creates a producer-consumer boundary where the consumer controls when the producer resumes, potentially with a different context.

Practical Impact: Logging and Tracing

csharp
1// Common pattern that breaks with IAsyncEnumerable
2static AsyncLocal<string> _correlationId = new AsyncLocal<string>();
3
4static async IAsyncEnumerable<Order> GetOrders()
5{
6    _logger.LogInformation($"Fetching orders, correlationId: {_correlationId.Value}");
7    // correlationId is correct here
8
9    yield return await FetchOrder(1);
10
11    _logger.LogInformation($"Fetching more, correlationId: {_correlationId.Value}");
12    // correlationId may be null here!
13}
14
15// Fix: capture at method start
16static async IAsyncEnumerable<Order> GetOrdersFixed()
17{
18    var correlationId = _correlationId.Value;  // Capture once
19
20    _logger.LogInformation($"Fetching orders, correlationId: {correlationId}");
21    yield return await FetchOrder(1);
22
23    _logger.LogInformation($"Fetching more, correlationId: {correlationId}");
24    // Always correct
25}

Common Pitfalls

  • Assuming AsyncLocal flows through yield: AsyncLocal values are lost at yield points because ExecutionContext is restored from the initial capture, not the last resume point.
  • Middleware setting AsyncLocal before IAsyncEnumerable: ASP.NET middleware that sets AsyncLocal (like correlation IDs) works for regular async methods but breaks when the response is produced by an IAsyncEnumerable endpoint.
  • Not capturing to a local variable: The simplest fix is var local = asyncLocal.Value; at the start of the method. This copies the value to the state machine's fields where it survives yield points.
  • Thinking this is a bug: This is intentional behavior. ExecutionContext copy-on-write semantics prevent downstream changes from affecting upstream contexts, which is correct for most async patterns.
  • Using AsyncLocal for DI-like patterns in iterators: If you use AsyncLocal as a service locator or ambient context, refactor to pass dependencies explicitly when using IAsyncEnumerable methods.

Summary

  • AsyncLocal<T> values are lost between yield return points because ExecutionContext is restored from the initial snapshot
  • Copy AsyncLocal values to local variables at the start of iterator methods to preserve them
  • Pass values explicitly through parameters instead of relying on AsyncLocal in IAsyncEnumerable methods
  • This behavior affects both IAsyncEnumerable and IEnumerable with yield
  • Regular async/await (without yield) preserves AsyncLocal values correctly

Course illustration
Course illustration

All Rights Reserved.