Async Streams

Vaibhav • September 11, 2025

In the previous article, we explored progress reporting using IProgress<T>, which allowed us to provide real-time feedback during asynchronous operations. Now we turn to a powerful feature introduced in C# 8.0 that makes working with asynchronous sequences much more natural: async streams. This feature lets you consume data asynchronously using await foreach, making it ideal for scenarios where data arrives over time-like reading from a network, processing files, or streaming events.

In this article, we’ll explore how async streams work, how to define and consume them, and how they integrate with cancellation and exception handling. We’ll also look at how they compare to traditional enumerables and tasks, and how to use them effectively in real-world applications.

What Are Async Streams?

An async stream is a method that returns an IAsyncEnumerable<T>. Unlike a regular IEnumerable<T>, which returns all items immediately, an async stream yields items one at a time as they become available. You consume them using await foreach, which pauses until the next item is ready.


async IAsyncEnumerable<int> GenerateNumbersAsync()
{
    for (int i = 0; i <= 5; i++)
    {
        await Task.Delay(500); // simulate delay
        yield return i;
    }
}
    

This method returns numbers from 0 to 5, one every half second. The yield return keyword emits each value, and await allows asynchronous work between items.

Consuming Async Streams

To consume an async stream, use await foreach. This works just like a regular foreach, but it waits for each item asynchronously.


await foreach (int number in GenerateNumbersAsync())
{
    Console.WriteLine($"Received: {number}");
}
    

This loop prints each number as it arrives. The await ensures that the loop doesn’t block the thread while waiting for the next item.

Async streams are ideal for scenarios where data arrives gradually-like reading lines from a file, receiving messages from a server, or processing user input.

Async Streams vs IEnumerable vs Task

Before async streams, you had two main options for returning data:

Use IEnumerable<T> for synchronous sequences, or Task<T> for a single asynchronous result. But what if you want to return multiple asynchronous results? You’d have to use List<T> with Task, which means collecting all items before returning.

Async streams solve this by combining yield return with await. You can return items one at a time, asynchronously, without waiting for the entire sequence to complete.

Real-World Example: Streaming File Lines

Let’s say you want to read a large file line by line, but you don’t want to load the entire file into memory. You can use async streams to read and yield each line as it’s read.


async IAsyncEnumerable<string> ReadLinesAsync(string path)
{
    using var reader = new StreamReader(path);

    while (!reader.EndOfStream)
    {
        string line = await reader.ReadLineAsync();
        yield return line;
    }
}
    

This method reads each line asynchronously and yields it. The caller can process each line as it arrives, without waiting for the entire file.


await foreach (string line in ReadLinesAsync("data.txt"))
{
    Console.WriteLine(line);
}
    

This loop prints each line from the file, one at a time. It’s efficient and memory-friendly, especially for large files.

Async Streams with Cancellation

You can add cancellation support to async streams using CancellationToken. This lets the caller stop the stream early-useful for user-triggered cancellation or timeouts.


async IAsyncEnumerable<int> CountAsync([EnumeratorCancellation] CancellationToken token)
{
    for (int i = 0; i < 100; i++)
    {
        token.ThrowIfCancellationRequested();
        await Task.Delay(100, token);
        yield return i;
    }
}
    

The EnumeratorCancellation attribute tells the compiler to pass the token to the enumerator. Inside the method, check the token and throw if cancelled.


var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));

await foreach (int value in CountAsync(cts.Token))
{
    Console.WriteLine(value);
}
    

This loop runs for 2 seconds, then cancels. The stream exits gracefully, and no more values are returned.

Exception Handling in Async Streams

You can use try-catch around await foreach to handle exceptions thrown by the stream. This works just like any other async method.


async IAsyncEnumerable<int> FaultyStream()
{
    yield return 1;
    await Task.Delay(500);
    throw new InvalidOperationException("Something went wrong");
}

try
{
    await foreach (int value in FaultyStream())
    {
        Console.WriteLine(value);
    }
}
catch (Exception ex)
{
    Console.WriteLine("Caught: " + ex.Message);
}
    

This stream throws an exception after yielding one value. The try-catch catches it and prints the error.

Combining Async Streams with LINQ

You can use LINQ-like methods with async streams, but they’re not built into System.Linq. Instead, use System.Linq.Async from the Microsoft.EntityFrameworkCore or System.Interactive.Async packages.


await foreach (var item in GenerateNumbersAsync().WhereAwait(async n => await Task.FromResult(n % 2 == 0)))
{
    Console.WriteLine($"Even: {item}");
}
    

This filters the stream to only even numbers. The WhereAwait method allows asynchronous filtering.

Common Mistakes

One common mistake is trying to use foreach instead of await foreach. Async streams require await because each item may arrive asynchronously.

Another mistake is forgetting to use EnumeratorCancellation when passing a cancellation token. Without it, the token won’t be passed correctly, and cancellation won’t work.

Also, don’t use yield return inside regular async methods. It only works in methods that return IAsyncEnumerable<T>.

You can use await foreach with Channel<T> for advanced producer-consumer scenarios. Channels provide more control over buffering and concurrency.

Summary

Async streams are a powerful feature that lets you return and consume asynchronous sequences using IAsyncEnumerable<T> and await foreach. They simplify scenarios where data arrives over time and integrate naturally with async workflows. You’ve learned how to define async streams, consume them with await foreach, handle cancellation and exceptions, and use them in real-world applications like file reading and data streaming.

In the next article, we’ll explore Deadlock Prevention, where we’ll learn how to avoid common pitfalls that cause async code to freeze or hang. You’ll understand how deadlocks happen, how to detect them, and how to design your code to stay responsive and safe.