If you've used async/await in C#, you know how to await a single value. But what about a stream of values that arrive asynchronously over time? That's where IAsyncEnumerable\<T\> comes in, and once you get it, you'll wonder how you lived without it.
The Problem: Async Data Streams
Imagine you're reading records from a database, processing messages from a queue, or tailing a log file. The data arrives a piece at a time, and each piece might take a moment to arrive. The classic approach is to load everything into a List\<T\> first:
public async Task<List<Order>> GetPendingOrdersAsync()
{
var orders = new List<Order>();
var reader = await _db.QueryAsync("SELECT * FROM orders WHERE status = 'pending'");
while (await reader.ReadAsync())
{
orders.Add(MapOrder(reader));
}
return orders; // caller waits for everything before seeing anything
}
This works, but it has a latency problem. The caller can't start processing until all records are loaded. If there are 50,000 orders, you're burning memory and making the caller wait.
Enter IAsyncEnumerable
IAsyncEnumerable\<T\> is the async counterpart to IEnumerable\<T\>. It lets a method produce items one at a time, asynchronously. You use yield return just like with regular iterators, but the method is async:
public async IAsyncEnumerable<Order> GetPendingOrdersAsync()
{
var reader = await _db.QueryAsync("SELECT * FROM orders WHERE status = 'pending'");
while (await reader.ReadAsync())
{
yield return MapOrder(reader);
}
}
The caller iterates with await foreach:
await foreach (var order in GetPendingOrdersAsync())
{
await ProcessOrderAsync(order);
}
Now each order is processed as soon as it arrives, rather than waiting for the full result set. For 50,000 records, that's a meaningful improvement.
A Realistic Example: Paginated API Calls
Let's say you're fetching all customers from a REST API that returns pages of 100 results. You want to process each customer as it arrives without accumulating the full list:
public async IAsyncEnumerable<Customer> GetAllCustomersAsync(
HttpClient client,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
int page = 1;
bool hasMore = true;
while (hasMore)
{
var response = await client.GetFromJsonAsync<PagedResult<Customer>>(
$"/api/customers?page={page}&size=100",
cancellationToken);
foreach (var customer in response!.Items)
{
yield return customer;
}
hasMore = response.HasNextPage;
page++;
}
}
Notice the [EnumeratorCancellation] attribute on the CancellationToken parameter. This wires cancellation into the await foreach loop automatically:
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await foreach (var customer in GetAllCustomersAsync(client, cts.Token))
{
await SendWelcomeEmailAsync(customer);
}
If the token is cancelled (or the timeout fires), the loop stops cleanly.
Filtering and Transforming Async Streams
LINQ doesn't work directly with IAsyncEnumerable\<T\> in the standard library, but the System.Linq.Async NuGet package adds full LINQ support:
// Install: dotnet add package System.Linq.Async
var highValueOrders = GetPendingOrdersAsync()
.Where(o => o.Total > 1000m)
.OrderByDescending(o => o.Total)
.Take(10);
await foreach (var order in highValueOrders)
{
Console.WriteLine($"{order.Id}: {order.Total:C}");
}
Without the package, you can write simple extensions yourself:
public static async IAsyncEnumerable<TResult> SelectAsync<T, TResult>(
this IAsyncEnumerable<T> source,
Func<T, TResult> selector)
{
await foreach (var item in source)
{
yield return selector(item);
}
}
Handling Errors and Cleanup
Because IAsyncEnumerable\<T\> methods are async iterators, you can use try/finally for cleanup:
public async IAsyncEnumerable<LogEntry> TailLogFileAsync(string path)
{
using var reader = new StreamReader(path);
try
{
while (true)
{
var line = await reader.ReadLineAsync();
if (line is not null)
{
yield return ParseLogEntry(line);
}
else
{
await Task.Delay(100); // wait a bit before polling again
}
}
}
finally
{
// reader is disposed here even if the caller stops iterating early
}
}
The finally block runs when the iterator is disposed — whether that's because you iterated to the end or broke out early.
Producing and Consuming Concurrently
One pattern you'll run into is wanting to produce items on a background task while consuming them on the calling thread. System.Threading.Channels pairs beautifully with IAsyncEnumerable\<T\> here:
public async IAsyncEnumerable<WorkItem> ProduceWorkItemsAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
var channel = Channel.CreateUnbounded<WorkItem>();
// Start producer in background
_ = Task.Run(async () =>
{
try
{
await foreach (var item in _queue.ReceiveAllAsync(ct))
{
await channel.Writer.WriteAsync(item, ct);
}
}
finally
{
channel.Writer.Complete();
}
}, ct);
// Expose as async stream
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
yield return item;
}
}
channel.Reader.ReadAllAsync() already returns an IAsyncEnumerable\<T\>, so this is just forwarding through, but the pattern scales well when you need to add buffering, batching, or transformation in between.
When to Use IAsyncEnumerable
It's the right tool when:
- You're reading from a database, file, or network and want to process results as they arrive
- You're calling a paginated API and want to abstract away the pagination
- You're building a pipeline where data flows through multiple async stages
- You want to expose a stream of events or notifications
It's not necessary when you have a small, bounded result set that's cheap to load all at once — in that case, a List\<T\> is simpler.
A Quick Gotcha: No Parallel Enumeration
One thing to watch out for: IAsyncEnumerable\<T\> is sequential by design. You can't iterate the same stream from two places at once. If you need parallel processing of stream items, pull them into a Channel\<T\> and use multiple consumers, or use Parallel.ForEachAsync:
await Parallel.ForEachAsync(
GetPendingOrdersAsync(),
new ParallelOptions { MaxDegreeOfParallelism = 4 },
async (order, ct) =>
{
await ProcessOrderAsync(order);
});
Parallel.ForEachAsync accepts IAsyncEnumerable\<T\> directly — it's one of those small .NET 6 additions that quietly made a big difference.
IAsyncEnumerable\<T\> fills the gap between "I have a result" (Task\<T\>) and "I have many results eventually" (async streams). Once you start applying it to database queries, API clients, and message consumers, synchronous bulk loading starts to feel like a step backward.
