C# Async Streams (IAsyncEnumerable)
An async stream is a sequence of values that arrives over time instead of all at once. In C#, async streams are represented by IAsyncEnumerable<T> and consumed with await foreach. They are useful when a program needs to read pages from an API, rows from a database, messages from a queue, or chunks from a file without blocking a thread or loading everything into memory.
Overview / How it works
A normal IEnumerable<T> is pull-based and synchronous: each call to its enumerator’s MoveNext method either produces the next item immediately or says the sequence is finished. That model is excellent for in-memory collections, but it is awkward when the next item depends on an asynchronous operation such as network I/O. Before async streams, developers often returned Task<List<T>>, which waits for every item before returning, or manually built callback/event systems.
IAsyncEnumerable<T> keeps the familiar streaming shape but makes each move asynchronous. The consumer asks for the next item, and the producer returns a ValueTask<bool> behind the scenes. If the item is ready, the result can complete synchronously; if not, the consumer awaits it without blocking a thread. This means the program can process the first result while later results are still being fetched.
The compiler does most of the hard work. When you write an async iterator method, meaning a method with async, a return type of IAsyncEnumerable<T>, and one or more yield return statements, the compiler rewrites it into a state machine. That state machine remembers local variables, the current position in the method, pending awaits, and the current yielded value. It also implements async cleanup so finally blocks can run if the consumer stops early.
Syntax
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
await foreach (int value in CountAsync(2))
{
Console.WriteLine(value);
}
}
static async IAsyncEnumerable<int> CountAsync(int count)
{
for (int i = 0; i < count; i++)
{
await Task.Delay(10);
yield return i;
}
}
}
IAsyncEnumerable<int>means the method returns an asynchronous sequence of integers.asyncallowsawaitinside the iterator method.yield returnpublishes one value to the consumer, then pauses the iterator.await foreachconsumes the sequence one item at a time, awaiting each asynchronous move.yield breakcan end an async iterator early.
| Type or keyword | Purpose |
|---|---|
IAsyncEnumerable<T> |
The async sequence that callers can enumerate. |
IAsyncEnumerator<T> |
The lower-level enumerator used by await foreach. |
await foreach |
Consumes an async stream. |
yield return |
Emits one item from an async iterator. |
await using |
Used by the compiler when async disposal is needed. |
Examples
A small async stream
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
await foreach (string message in GetStatusMessagesAsync())
{
Console.WriteLine(message);
}
}
static async IAsyncEnumerable<string> GetStatusMessagesAsync()
{
yield return "Starting";
await Task.Delay(10);
yield return "Reading";
await Task.Delay(10);
yield return "Finished";
}
}
Output:
Starting
Reading
Finished
This example returns messages one at a time. The two calls to Task.Delay stand in for real asynchronous work. The consumer does not receive a list; it receives each message as soon as the iterator reaches a yield return.
Stopping early still cleans up
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
await foreach (string page in ReadPagesAsync())
{
Console.WriteLine("Consumer got " + page);
if (page == "page-2")
{
break;
}
}
}
static async IAsyncEnumerable<string> ReadPagesAsync()
{
Console.WriteLine("Opening source");
try
{
for (int page = 1; page <= 4; page++)
{
await Task.Delay(10);
Console.WriteLine("Producer ready " + page);
yield return "page-" + page;
}
}
finally
{
Console.WriteLine("Cleaning up source");
}
}
}
Output:
Opening source
Producer ready 1
Consumer got page-1
Producer ready 2
Consumer got page-2
Cleaning up source
The consumer stops after page-2. Even though the iterator never reaches pages 3 and 4, the finally block runs. This matters for real streams that hold files, sockets, database readers, or subscriptions. await foreach disposes the async enumerator when the loop ends, including when it ends because of break.
Streaming paged API results
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
await foreach (Order order in GetLargeOrdersAsync(minimumTotal: 100))
{
Console.WriteLine(order.Id + ": $" + order.Total);
}
}
static async IAsyncEnumerable<Order> GetLargeOrdersAsync(decimal minimumTotal)
{
int page = 1;
while (true)
{
List<Order> orders = await FetchOrdersPageAsync(page);
if (orders.Count == 0)
{
yield break;
}
foreach (Order order in orders)
{
if (order.Total >= minimumTotal)
{
yield return order;
}
}
page++;
}
}
static async Task<List<Order>> FetchOrdersPageAsync(int page)
{
await Task.Delay(10);
if (page == 1)
{
return new List<Order>
{
new Order(101, 45m),
new Order(102, 130m)
};
}
if (page == 2)
{
return new List<Order>
{
new Order(103, 220m),
new Order(104, 80m)
};
}
return new List<Order>();
}
}
record Order(int Id, decimal Total);
Output:
102: $130
103: $220
This is the common production pattern: request one page, yield matching items, then request the next page only when the consumer asks for more. The program never stores all orders at once. If the caller breaks early after the first large order, later pages are never fetched.
How it works step by step / Under the hood
- The caller invokes the async iterator method. The method body does not run to completion immediately; the compiler returns an object that represents the async sequence.
await foreachasks that object for an async enumerator.- For each loop iteration, the compiler awaits the enumerator’s asynchronous move operation. Internally this is similar to awaiting
MoveNextAsync(). - The iterator runs until it hits
yield return,yield break, the end of the method, or an incompleteawait. - When
yield returnis reached, the current value is stored in the state machine and made available to the loop variable. - When the consumer asks for the next item, execution resumes after the previous
yield return. - If the loop exits early, the enumerator is disposed asynchronously so cleanup code can run.
The important mental model is lazy asynchronous pulling. The producer does not push values whenever it wants; the consumer requests the next value. That keeps backpressure simple: if the consumer is slow, the producer naturally waits before doing more work.
Common Mistakes
Using foreach instead of await foreach
IAsyncEnumerable<int> numbers = CountAsync(3);
foreach (int number in numbers)
{
Console.WriteLine(number);
}
This is wrong because IAsyncEnumerable<T> is not a synchronous enumerable. The next item might require an awaited operation, so the loop itself must be asynchronous.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
await foreach (int number in CountAsync(3))
{
Console.WriteLine(number);
}
}
static async IAsyncEnumerable<int> CountAsync(int count)
{
for (int i = 1; i <= count; i++)
{
await Task.Delay(10);
yield return i;
}
}
}
Output:
1
2
3
Assuming enumeration is cached
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
IAsyncEnumerable<int> numbers = GenerateAsync();
await foreach (int number in numbers)
{
Console.WriteLine("First: " + number);
}
await foreach (int number in numbers)
{
Console.WriteLine("Second: " + number);
}
}
static async IAsyncEnumerable<int> GenerateAsync()
{
Console.WriteLine("Starting generator");
await Task.Delay(10);
yield return 1;
yield return 2;
}
}
Output:
Starting generator
First: 1
First: 2
Starting generator
Second: 1
Second: 2
This code compiles, but it may surprise you. An async stream is usually a recipe for producing values, not a saved collection. Enumerating it twice usually reruns the iterator and repeats I/O. If you need a reusable result, materialize it into a list by collecting items once.
Best Practices
- Use
IAsyncEnumerable<T>when items can be processed incrementally and each item or page may require asynchronous work. - Use
Task<List<T>>when the caller truly needs the whole result before doing anything. - Keep iterator methods lazy. Do not fetch all pages before the first
yield return. - Put cleanup in
finallyblocks or use async-disposable resources carefully, because consumers can stop early. - Document whether a stream can be enumerated more than once and what repeated enumeration does.
- Prefer cancellation support for long-running streams. In library code, accept a
CancellationTokenand check it during waits or between pages. - Avoid blocking calls such as
Thread.Sleepinside async iterators; use awaitable APIs such asTask.Delayor real asynchronous I/O.
Practice Exercises
- Write an async iterator named
CountdownAsyncthat yields 3, 2, 1 with a short delay before each value. Consume it withawait foreach. - Create an async stream that reads two simulated pages of product names and yields only names longer than five characters.
- Modify the paged orders example so the consumer stops after the first matching order. Confirm that pages after the break are not fetched.
Summary
IAsyncEnumerable<T>represents a sequence whose next value may arrive asynchronously.await foreachis the normal way to consume async streams.- Async iterator methods combine
async,await, andyield return. - The compiler turns async iterators into state machines that preserve local state between items.
- Async streams are lazy, memory-efficient, and naturally support incremental processing.
- Stopping early still triggers disposal, so cleanup belongs in
finallyor async-disposable resources.
