C# & .NET (C# 14, .NET 10 LTS) Module 15 – Asynchronous programming
Async streams with IAsyncEnumerable
Produce C# async streams with yield return, read them with await foreach, pass a token with EnumeratorCancellation, and query them with AsyncEnumerable.
What you will learn
- Write and consume async streams with yield return and await foreach
- Predict how far an async iterator runs when its consumer stops early
- Query async streams with the built-in System.Linq.AsyncEnumerable
- Pass cancellation into async iterators with [EnumeratorCancellation]
- Read JSON and NDJSON input one value at a time, without loading it whole
Before you start
What you’ll learn · Before you start
What you will learn
- Write and consume async streams with yield return and await foreach
- Predict how far an async iterator runs when its consumer stops early
- Query async streams with the built-in System.Linq.AsyncEnumerable
- Pass cancellation into async iterators with [EnumeratorCancellation]
- Read JSON and NDJSON input one value at a time, without loading it whole
Before you start
On this page
A report reads 3 pages of orders from an API that needs 100 ms per page. Collected into a list first, the first order reaches the report after all 3 pages, 300 ms; read as an async stream, it arrives after the first page, 100 ms, and only one page is in memory at a time.
You will write async streams with yield return, read them with await foreach, see how far a stream runs when its
reader stops early, and query streams with the LINQ operators that .NET 10 ships.
An async iterator hands over each item as soon as it has it
pages.cs reads 3 pages of 2 orders each, first as a stream, then collected into a list:
// An async stream: an async method that returns IAsyncEnumerable<T> and uses yield return. Each order is handed to
// the loop as soon as its page has arrived, while the next page is still to be fetched.
await foreach (Order order in OrdersAsync())
{
Console.WriteLine($" the loop got order {order.Id} ({order.Amount})");
}
// The same orders collected into a list first: nothing reaches the caller before the last page has arrived.
List<Order> all = await AllOrdersAsync();
Console.WriteLine($"the list arrived with {all.Count} orders, after {Api.PagesFetched} pages");
static async IAsyncEnumerable<Order> OrdersAsync()
{
for (int page = 1; page <= 3; page++)
{
Order[] orders = await Api.GetPageAsync(page);
foreach (Order order in orders)
{
yield return order;
}
}
}
static async Task<List<Order>> AllOrdersAsync()
{
Api.PagesFetched = 0;
var all = new List<Order>();
for (int page = 1; page <= 3; page++)
{
all.AddRange(await Api.GetPageAsync(page, quiet: true));
}
return all;
}
static class Api
{
public static int PagesFetched;
// A paginated API: page n holds orders n01 to n02, and every page takes 100 ms to arrive.
public static async Task<Order[]> GetPageAsync(int page, bool quiet = false)
{
if (!quiet)
{
Console.WriteLine($"fetching page {page}");
}
await Task.Delay(100);
PagesFetched++;
return [new(page * 100 + 1, 250 * page), new(page * 100 + 2, 120 * page)];
}
}
record Order(int Id, int Amount); Output
fetching page 1 the loop got order 101 (250) the loop got order 102 (120) fetching page 2 the loop got order 201 (500) the loop got order 202 (240) fetching page 3 the loop got order 301 (750) the loop got order 302 (360) the list arrived with 6 orders, after 3 pages
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run pages.cs
OrdersAsync is an async iterator: An async method that returns IAsyncEnumerable<T> and uses yield return. Its body runs only as its reader asks for items, each time up to the next yield return.: an async method that returns
IAsyncEnumerable<Order> and uses yield return. Its result is an async stream: A sequence whose items arrive asynchronously, typed IAsyncEnumerable<T>. An async iterator produces it with yield return, and await foreach reads it one item at a time., and
await foreach reads it. The run interleaved the two: the loop printed orders 101 and 102 before page 2 was even
fetched, while the list version handed over nothing until the last page had arrived. Step through the first orders:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The loop asks. await foreach calls GetAsyncEnumerator, then MoveNextAsync. Only now does the body of OrdersAsync start: calling it ran none of its code.
- Page 1 is fetched. The iterator prints "fetching page 1" and awaits the API, so MoveNextAsync returns a task that is not complete. Nothing waits on a thread for the 100 ms.
- Page 1 arrives. The page arrives with orders 101 and 102. The iterator resumes after its await.
- yield return 101. At yield return the iterator hands order 101 to the loop and stops where it is, with its page and its place kept. The loop prints "the loop got order 101".
- The next item is ready. The next MoveNextAsync resumes the iterator after its yield. Order 102 is already in the page, so it comes back at once, without a call to the API.
- Page 2, only when asked. Only when the loop asks for a third order does the iterator fetch page 2, while the orders of page 1 have already been used.
- Order 201. Page 2 arrives and the iterator yields order 201. The pattern repeats until page 3 is used up and MoveNextAsync answers false.
The interfaces mirror the ones a foreach uses: IAsyncEnumerable<T> hands out an IAsyncEnumerator<T>, whose
MoveNextAsync returns a ValueTask<bool> that the loop awaits, and the enumerator is disposed asynchronously when the
loop ends. Async streams came with C# 8 and .NET Core 3.0.
Checkpoint
Check: Async iterators and await foreach
await foreach pulls one item at a time, so the reader sets the pace
early.cs reads a stream of 5 pages and stops after 3 orders:
// await foreach asks for one item at a time, so the producer runs only as far as the consumer reads. Breaking out
// of the loop disposes the stream: the iterator's finally block runs, and the pages after it are never fetched.
int pagesFetched = 0;
int seen = 0;
await foreach (int id in OrderIdsAsync())
{
Console.WriteLine($" the loop got order {id}");
if (++seen == 3)
{
Console.WriteLine("the loop breaks after 3 orders");
break;
}
}
Console.WriteLine($"pages fetched: {pagesFetched} of 5");
async IAsyncEnumerable<int> OrderIdsAsync()
{
try
{
for (int page = 1; page <= 5; page++)
{
Console.WriteLine($"fetching page {page}");
await Task.Delay(100);
pagesFetched++;
yield return page * 100 + 1;
yield return page * 100 + 2;
}
}
finally
{
Console.WriteLine("the iterator's finally block runs: connection closed");
}
} Output
fetching page 1 the loop got order 101 the loop got order 102 fetching page 2 the loop got order 201 the loop breaks after 3 orders the iterator's finally block runs: connection closed pages fetched: 2 of 5
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run early.cs
The iterator fetched page 2 only when the loop asked for a third order, and when the loop broke out, the iterator’s
finally block ran, printing “connection closed”, because leaving await foreach disposes the enumerator. Pages 3,
4 and 5 were never fetched: 2 pages of 5. A stream runs exactly as far as its reader asks, so a fast producer cannot
bury a slow consumer under items. This built-in back-pressure: A consumer setting the pace of its producer. An async stream has it built in: the producer runs only when the consumer asks for the next item, so items never pile up between them. is the difference from a
buffer: when the producer should run ahead of its reader, as when messages arrive whether or not anyone reads them, a
channel holds them in between, a tool of the concurrency module.
Cleanup after the last yield return
Code after the loop of an iterator runs only if the reader reads to the end. Put cleanup, such as closing a
connection, in a finally block or a using declaration, which also run when the reader stops early.
Checkpoint
Check: How far a stream runs for its reader
.NET 10 has LINQ for async streams built in
linq.cs filters, maps and totals a stream with the System.Linq.AsyncEnumerable class:
// .NET 10 has LINQ for async streams in the shared framework: System.Linq.AsyncEnumerable. Its operators
// read an IAsyncEnumerable<T> and return one, and the ...Async methods (ToListAsync, SumAsync) end the query.
IAsyncEnumerable<int> squares = AsyncEnumerable.Range(1, 10)
.Where(n => n % 2 == 0)
.Select(n => n * n);
List<int> list = await squares.ToListAsync();
Console.WriteLine($"even squares: {string.Join(",", list)}");
Console.WriteLine($"their sum: {await squares.SumAsync()}");
Console.WriteLine($"AsyncEnumerable is in the assembly {typeof(AsyncEnumerable).Assembly.GetName().Name}");
// The operators also take async lambdas, which get the stream's cancellation token.
IAsyncEnumerable<string> labels = AsyncEnumerable.Range(1, 3)
.Select(async (int n, CancellationToken cancellationToken) =>
{
await Task.Delay(10, cancellationToken); // a lookup per item
return $"order {n}";
});
Console.WriteLine($"labels: {string.Join("; ", await labels.ToListAsync())}"); Output
even squares: 4,16,36,64,100 their sum: 220 AsyncEnumerable is in the assembly System.Linq.AsyncEnumerable labels: order 1; order 2; order 3
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run linq.cs
Where and Select return new streams, and nothing runs until ToListAsync or SumAsync reads them: the even
squares 4, 16, 36, 64 and 100, with the sum 220. The operators also take async lambdas, which receive the stream’s
cancellation token. AsyncEnumerable came from the assembly System.Linq.AsyncEnumerable, which is part of .NET 10.
New in .NET 10
Before .NET 10 these operators came from the community System.Linq.Async package. Microsoft lists the change as a
breaking change: a project that references that package gets ambiguous calls on .NET 10, and removes the reference
or moves to version 7.0.0 of the package.
Two more tools complete the set. [EnumeratorCancellation] marks the iterator parameter that receives the token of
WithCancellation. It goes on the iterator’s CancellationToken parameter, written last with default as its
value, as in Microsoft’s tutorial. JsonSerializer.DeserializeAsyncEnumerable reads a JSON array, or one JSON value
per line, as an async stream.
Checkpoint
Check: LINQ for async streams in .NET 10
Cancelling an async stream: [EnumeratorCancellation] and WithCancellation Pro: opens with a Pro, Premium or Ultimate pass
A token can reach an async iterator in two ways, and one of them fails without an error. This part runs all four combinations side by side and shows the compiler warning that catches the silent one.
In this part:
- Four ways a token can reach an iterator, run side by side
- The warning that says a token goes nowhere: CS8425
3 runnable examples (C#, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Streaming JSON without loading it: DeserializeAsyncEnumerable, measured Pro: opens with a Pro, Premium or Ultimate pass
JsonSerializer.DeserializeAsyncEnumerable turns a JSON array, or a stream of JSON values, into an async stream.
This part measures how early the first item arrives, reads NDJSON-style values, and reproduces what one broken value
does.
In this part:
- A JSON array read 64 bytes at a time
- One value per line, and what a broken line does
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
A lab: read NDJSON orders lazily, skip bad lines and total them Pro: opens with a Pro, Premium or Ultimate pass
The lab combines the three ideas of this lesson: an async iterator that yields as it reads, a token that stops it, and an AsyncEnumerable query that totals what it yields. Five checks say when it is right.
In this part:
- The rules and the five checks
- Building it: a line loop, a guarded parse, a token
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise
The self-check covers the first three sections: when an async iterator runs, how far it runs for a reader that stops early, and what the AsyncEnumerable operators return. Your weakest topic gets the first question, and each choice comes with its reason:
Practice that adapts to you
Practise this lesson
6 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is the difference between IEnumerable<T> and IAsyncEnumerable<T>?
Both are sequences read one item at a time, and both can be written as iterators with yield return. With
IEnumerable<T>, MoveNext returns a bool at once, so getting an item that needs I/O would block the thread. With
IAsyncEnumerable<T>, MoveNextAsync returns a ValueTask<bool> that the reader awaits, so the producer can await a
page, a row or a message between items without holding a thread. You read it with await foreach, which also disposes
the enumerator asynchronously when the loop ends or breaks. Choose IAsyncEnumerable<T> when items arrive over time
from I/O, and IEnumerable<T> when they are computed or already in memory.
-
Mid to senior
An export endpoint loads 2 million order rows into a List<Order>, serialises the list and sends it. At peak the service runs out of memory. Rewrite its data path with async streams, and say what changes for memory, the time to the first byte and cancellation.
-
Mid to senior
A dashboard stops reading a sensor stream with WithCancellation(token), but the sensor connection stays open and keeps polling. The async iterator takes a CancellationToken parameter and passes it to every call. What is wrong, how do you prove it, and how do you fix it?
-
Senior to staff
A worker reads messages from a queue faster than it can write them to a database. Would you connect the reading and the writing with an IAsyncEnumerable<T> or a Channel<T>? Explain back-pressure in both, and what happens when the writer fails.
Key takeaways
- An async iterator, an
asyncmethod that returnsIAsyncEnumerable<T>withyield return, hands over each item as soon as it has it, andawait foreachreads it. - The iterator runs only as far as its reader asks, and leaving the loop early disposes it, which runs its
finallyblocks; that is back-pressure without a buffer. - .NET 10 ships LINQ for async streams in
System.Linq.AsyncEnumerable, whose operators run only when a method such asToListAsyncreads the result.
Terms in this lesson
- async iterator
- An async method that returns IAsyncEnumerable<T> and uses yield return. Its body runs only as its reader asks for items, each time up to the next yield return.
- async stream
- A sequence whose items arrive asynchronously, typed IAsyncEnumerable<T>. An async iterator produces it with yield return, and await foreach reads it one item at a time.
- back-pressure
- A consumer setting the pace of its producer. An async stream has it built in: the producer runs only when the consumer asks for the next item, so items never pile up between them.
References
- Generate and consume async streams (Microsoft Learn)
- Iteration statements: await foreach (Microsoft Learn)
- IAsyncEnumerable<T> interface (Microsoft Learn)
- AsyncEnumerable class (Microsoft Learn)
- Breaking change: System.Linq.AsyncEnumerable in .NET 10 (Microsoft Learn)
- EnumeratorCancellationAttribute class (Microsoft Learn)
- JsonSerializer.DeserializeAsyncEnumerable method (Microsoft Learn)
- What's new in .NET libraries for .NET 10 (Microsoft Learn)
- Errors and warnings that involve async, await and the Task-asynchronous protocol (Microsoft Learn)
- The history of C# (Microsoft Learn)
- Errors and warnings for iterator methods and yield return (Microsoft Learn)
- JsonSerializerOptions.DefaultBufferSize property (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress