Back to Blog
C#

Using C# await foreach with IAsyncEnumerable

Learn how to consume and produce asynchronous streams with C# await foreach, including cancellation, error handling, and runtime requirements.

async streamsIAsyncEnumerableC# 8asynchronous programmingyield return
Illustration of an asynchronous stream being consumed by a foreach loop with an await keyword, symbolizing c# await foreach.

To use await foreach effectively, you need to understand the core syntax, the runtime behavior behind it, and the practical patterns in the examples below.

C# 8 introduced await foreach to consume asynchronous streams. Before this feature, working with a sequence whose items arrived asynchronously usually meant buffering the whole collection or writing a custom pull-based loop. await foreach works with IAsyncEnumerable<T> and gives you a natural, familiar syntax that mirrors the synchronous foreach while preserving the non-blocking behavior of async code.

What await foreach Solves

A synchronous foreach blocks the current thread while the sequence is produced. If the data comes from a network call, a database query, or a background service, that blocking wait can waste a thread and stall the application. await foreach lets you iterate over a sequence in which each element is produced asynchronously. The loop awaits the next element without occupying a thread while waiting.

Consider a service that returns records from a remote API. With a synchronous List<T>, you wait for the entire response before you can process the first record. With an IAsyncEnumerable<T>, you can process each record as soon as it arrives, reducing latency and memory pressure.

Consuming an IAsyncEnumerable

The simplest usage of await foreach looks like this:

await foreach (var item in GetItemsAsync()) { Console.WriteLine(item); }

GetItemsAsync returns IAsyncEnumerable<string>. The compiler transforms the loop into a state machine that obtains an async enumerator, calls MoveNextAsync(), awaits the result, and disposes the enumerator when done. By default, each await in this generated loop resumes on the captured synchronization context.

You can use await foreach inside an async method. The containing method must be marked async; typical return types are Task, Task<T>, or, for an async iterator, IAsyncEnumerable<T>. You cannot use await foreach in a synchronous method.

Producing an IAsyncEnumerable with yield return

Writing an asynchronous stream is straightforward: write an iterator method that returns IAsyncEnumerable<T> and use yield return. The method can also use await before yielding a value, which a synchronous iterator cannot do.

async IAsyncEnumerable<int> GenerateNumbersAsync() { for (int i = 0; i < 10; i++) { await Task.Delay(100); // Simulate async work yield return i; } }

Each time the consumer calls MoveNextAsync, the iterator resumes after the previous yield return. The await Task.Delay runs asynchronously, so the thread is free while waiting. The compiler generates a state machine that handles both the async operation and the iterator state.

You can combine await with yield return in any order. For example, you might fetch a page of results from an API, yield each item, then fetch the next page. This pattern is common when paginating through large datasets.

Cancellation

Asynchronous streams often represent long-running operations. You should support cancellation so the consumer can stop iteration early. The standard approach is to pass a CancellationToken to the producer method and check it inside the iterator.

async IAsyncEnumerable<int> GenerateNumbersAsync(CancellationToken cancellationToken) { for (int i = 0; i < 100; i++) { cancellationToken.ThrowIfCancellationRequested(); await Task.Delay(100, cancellationToken); yield return i; } }

The consumer can use a CancellationTokenSource to request cancellation. The await foreach syntax itself has no token parameter, but the WithCancellation extension attaches a token to an async enumerable:

await foreach (var item in source.WithCancellation(cancellationToken)) { // Process item. }

When you use WithCancellation, the token is passed to the source's GetAsyncEnumerator(CancellationToken) method. To receive that token in an async iterator you write, mark the cancellation parameter with [EnumeratorCancellation], which is in System.Runtime.CompilerServices:

async IAsyncEnumerable<int> GenerateNumbersAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) { // Same checks and awaits as the earlier example. }

The preceding pattern lets the consumer pass a token through WithCancellation. If the producer exposes an ordinary CancellationToken parameter instead, you can pass a token directly at the call site. Either way, the producer needs a token to observe cancellation; without one, a canceled CancellationTokenSource does not signal the iterator.

If the producer uses Task.Delay with the token, cancellation throws OperationCanceledException inside the iterator. You can catch that exception in the producer and exit gracefully, or let it propagate to the consumer. The consumer can catch OperationCanceledException around the await foreach loop to handle cancellation.

You can also break out of an await foreach loop manually. The iterator's DisposeAsync is called, which lets the producer clean up resources. However, breaking out does not signal cancellation by itself; use a token when the producer needs to react to early termination.

Error Handling in Asynchronous Streams

Errors can occur at any point during iteration. The producer might throw when fetching the next element, or the consumer might throw while processing an element. The behavior is similar to a synchronous iterator: an exception thrown inside the iterator propagates to the consumer at the MoveNextAsync call.

async IAsyncEnumerable<string> ReadLinesAsync() { using var reader = new StreamReader("data.txt"); string? line; while ((line = await reader.ReadLineAsync()) != null) { yield return line; } }

If ReadLineAsync throws, the exception surfaces at the await foreach in the consumer. You can wrap the loop in a try-catch to handle it. Because the iterator is a state machine, the finally blocks inside the iterator run when the consumer disposes the enumerator, even if an exception occurs.

One important detail: if the consumer breaks out of the loop early, the enumerator's DisposeAsync is called. The iterator's finally blocks execute asynchronously. This allows you to release resources like file handles or database connections without leaking them.

Performance Considerations

await foreach avoids buffering the entire sequence, which reduces memory usage when working with large datasets. It also avoids blocking threads, which improves scalability in server applications. The tradeoff is overhead compared with a synchronous foreach, because each iteration uses an asynchronous state machine and continuation scheduling.

For most I/O-bound scenarios, that overhead is small compared with the cost of the underlying operation. But if you are iterating over a hot, CPU-bound loop with millions of elements and the producer is purely CPU-bound, a synchronous List<T> or an array will be faster. Use IAsyncEnumerable<T> when the data source is genuinely asynchronous, such as a network stream, a database cursor, or a message queue.

Another consideration is the synchronization context. By default, iteration resumes on the captured synchronization context. In a UI application, that means continuation work runs on the UI thread, which can affect responsiveness if the producer or consumer does heavy work there. Most ASP.NET Core web apps do not install a synchronization context, so continuations usually run on thread pool threads. Be aware of these differences when designing your producer.

Compatibility and Runtime Requirements

await foreach requires C# 8 or later. It also requires the IAsyncEnumerable<T> types. Those types are part of .NET Standard 2.1, so they are available in .NET Core 3.0 and later and in .NET 5 and later. For .NET Framework or .NET Standard 2.0 targets, install the Microsoft.Bcl.AsyncInterfaces NuGet package to provide the necessary types. In modern .NET, these types are included in the base library.

If a library returns IAsyncEnumerable<T>, you can consume it with await foreach as long as your project has the required language version and runtime types. If you write a method that returns IAsyncEnumerable<T>, consumers on older runtimes need to upgrade or install the compatibility package to consume it with await foreach.

Advanced Pattern: Combining Multiple Streams

You can use await foreach with other asynchronous features. There is no built-in language operator for merging two async streams, so a channel-based helper is useful when you want both producers to run concurrently. Iterating over them sequentially is simplest, but it does not let both producers run concurrently.

async IAsyncEnumerable<T> MergeAsync<T>(IAsyncEnumerable<T> first, IAsyncEnumerable<T> second) { var channel = Channel.CreateUnbounded<T>(); var writeTasks = new[] { WriteAllAsync(first, channel.Writer), WriteAllAsync(second, channel.Writer) }; _ = CompleteWriterWhenFinishedAsync(writeTasks, channel.Writer); await foreach (var item in channel.Reader.ReadAllAsync()) { yield return item; } } static async Task WriteAllAsync<T>(IAsyncEnumerable<T> source, ChannelWriter<T> writer) { await foreach (var item in source) { await writer.WriteAsync(item); } } static async Task CompleteWriterWhenFinishedAsync(Task[] writeTasks, ChannelWriter<T> writer) { try { await Task.WhenAll(writeTasks); writer.TryComplete(); } catch (Exception ex) { writer.TryComplete(ex); } }

This pattern preserves streaming behavior while allowing both producers to run concurrently. The channel buffers items until the consumer is ready, which adds memory overhead and can grow if one producer outpaces the consumer. In production code, you would also pass a cancellation token into the write tasks so an early exit from the merged stream can stop the background producers.

C# await foreach: Consuming IAsyncEnumerable Streams | RYUSLOG DEV