Mert Özen Further With Every Line
Backend Asynchronous Programming in C# — #11

C# IAsyncEnumerable, await foreach and Async Dispose

Mert Özen Nov 7, 2026 9 min 2 views
C# IAsyncEnumerable, await foreach and Async Dispose

Collecting books from a paged API with Task<List<T>> leaves the first element until last; with an async iterator, yield return and await together let the first page flow right away. What await foreach expands to and MoveNextAsync's ValueTask, EnumeratorCancellation and CS8425, the finally that runs on break, IAsyncDisposable and await using, DeserializeAsyncEnumerable and streaming in ASP.NET Core.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

The catalog API hands out books page by page: two books per page, and each page request takes 200 ms. We wrote a method that collects all three pages into a list and returns it, and we print the result. At which millisecond does the first book title appear on the screen?

Stopwatch sw = Stopwatch.StartNew();
foreach (Book book in await GetAllBooksAsync())
{
    Console.WriteLine($"{sw.ElapsedMilliseconds,4} ms  {book.Title}");
}
static async Task<List<Book>> GetAllBooksAsync()
{
    List<Book> all = new();
    for (int page = 1; page <= 3; page++)
    {
        all.AddRange(await Catalog.GetPageAsync(page));
    }
    return all;
}
record Book(string Isbn, string Title);

static class Catalog
{
    private static readonly Book[] Books =
    {
        new("978-0441172719", "Dune"),
        new("978-0553293357", "Foundation"),
        new("978-0261102217", "The Hobbit"),
        new("978-0547928227", "The Lord of the Rings"),
        new("978-0451524935", "1984"),
        new("978-0060850524", "Brave New World"),
    };

    // each page is an API call that takes 200 ms
    public static async Task<Book[]> GetPageAsync(int page, CancellationToken cancellationToken = default)
    {
        await Task.Delay(200, cancellationToken);
        return Books.Skip((page - 1) * 2).Take(2).ToArray();
    }
}

Collect Everything, Then Return

The output (there is a using System.Diagnostics; line at the top of the file):

 607 ms  Dune
 609 ms  Foundation
 609 ms  The Hobbit
 609 ms  The Lord of the Rings
 609 ms  1984
 609 ms  Brave New World

The first book arrived at 607 ms, that is after all three pages were done. We actually had Dune at 200 ms, but Task<List<Book>> means a single result: nothing can come out until the list is complete. The user stares at an empty screen for 600 ms, and all the books sit in memory at the same time. With six books that is fine; with an export of ten thousand pages, both the waiting and the memory become a problem.

A plain IEnumerable<Book> with yield return does not help either, because MoveNext() is a synchronous method: if it has to wait on the network for the next element, it blocks the thread. We need a type that can say both "hand them out piece by piece" and "let go of the thread while waiting."

Think of watching a series. A platform can shoot and edit the whole season and release it all at once, or it can release each episode in the week it is ready. In the first case you wait for the whole season to watch the first episode. In the second you start watching on the day the first episode is ready. Task<List<T>> is the first, IAsyncEnumerable<T> the second.

Async Iterators: yield return and await Together

IAsyncEnumerable<T>, which came with C# 8 and .NET Core 3.0, is the answer to this need. When you make the method async and write IAsyncEnumerable<Book> as its return type, you can use both await and yield return inside it:

static async IAsyncEnumerable<Book> GetBooksAsync()
{
    for (int page = 1; page <= 3; page++)
    {
        foreach (Book book in await Catalog.GetPageAsync(page))
        {
            yield return book; // hand out the books one by one as soon as the page arrives
        }
    }
}

The consuming side uses the async counterpart of foreach:

await foreach (Book book in GetBooksAsync())
{
    Console.WriteLine($"{sw.ElapsedMilliseconds,4} ms  {book.Title}");
}
 202 ms  Dune
 202 ms  Foundation
 404 ms  The Hobbit
 404 ms  The Lord of the Rings
 606 ms  1984
 606 ms  Brave New World

The first two books arrived at 202 ms, because as soon as the first page came down, yield return handed them out one by one. The total is still about 600 ms; we did not finish faster, but we did not sit idle while waiting for the result. And at any moment there is at most one page in memory.

For such a method the compiler generates a single state machine that does the work of both an iterator and an async method. At yield return the method pauses and gives the element to the consumer. When the consumer asks for the next one, the method resumes where it left off; if it reaches an await in the meantime, it lets go of the thread and waits.

Behind await foreach

await foreach is shorthand for a few lines the compiler writes for you. Written by hand, it looks like this:

IAsyncEnumerator<Book> enumerator = GetBooksAsync().GetAsyncEnumerator();
try
{
    while (await enumerator.MoveNextAsync()) // MoveNextAsync returns a ValueTask<bool>
    {
        Book book = enumerator.Current;
        Console.WriteLine(book.Title);
    }
}
finally
{
    await enumerator.DisposeAsync();
}
Dune
Foundation

Two details stand out. MoveNextAsync returns a ValueTask<bool>. In an async sequence, most elements are already ready in a buffer; in our example, once a page arrives there is no need to wait for the second book. Not allocating a Task for every element is exactly the use case for ValueTask we saw in the previous post. And the rules are followed: each ValueTask is awaited once, immediately.

Second, however the loop ends, DisposeAsync is called in the finally block. We will see why that matters with early exits in a moment.

Cancellation: EnumeratorCancellation

The right way to pass a token to an async iterator has two parts. You put the [EnumeratorCancellation] attribute on the method's parameter, and you give the token on the consumer side with WithCancellation:

static async IAsyncEnumerable<Book> GetBooksAsync(
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    for (int page = 1; page <= 3; page++)
    {
        foreach (Book book in await Catalog.GetPageAsync(page, cancellationToken))
        {
            yield return book;
        }
    }
}
using CancellationTokenSource cts = new(TimeSpan.FromMilliseconds(500));

try
{
    await foreach (Book book in GetBooksAsync().WithCancellation(cts.Token))
    {
        Console.WriteLine(book.Title);
    }
}
catch (OperationCanceledException)
{
    Console.WriteLine("Canceled while waiting for the next page");
}
Dune
Foundation
The Hobbit
The Lord of the Rings
Canceled while waiting for the next page

At 500 ms the token was canceled. At that moment the third page was being awaited, and Task.Delay noticed the cancellation and threw. Four books made it to the screen, the fifth did not. The attribute needs the System.Runtime.CompilerServices namespace.

What happens if you forget the attribute? When we compiled the same program without [EnumeratorCancellation], the compiler gave this warning and cancellation never worked:

Program.cs(16,37): warning CS8425: Async-iterator 'GetBooksAsync(CancellationToken)' has one or more parameters of type 'CancellationToken' but none of them is decorated with the 'EnumeratorCancellation' attribute, so the cancellation token parameter from the generated 'IAsyncEnumerable<>.GetAsyncEnumerator' will be unconsumed
Dune
Foundation
The Hobbit
The Lord of the Rings
1984
Brave New World

The token you give with WithCancellation goes to the enumerator, but it is not bound to the method's parameter. The parameter stays at its default value and all six books arrive. Always treat this warning like an error. With the attribute in place you can also pass the token directly as GetBooksAsync(cts.Token); both ways reach the same parameter.

Early Exit and finally

Async iterators often hold a resource behind them: a database connection, a file, an HTTP response. What happens to that resource if the consumer leaves the loop early?

await foreach (Book book in GetBooksAsync())
{
    Console.WriteLine($"Found: {book.Title}");
    break; // the first book is enough
}
Console.WriteLine("After the loop");

static async IAsyncEnumerable<Book> GetBooksAsync()
{
    Console.WriteLine("Opening the catalog connection");
    try
    {
        for (int page = 1; page <= 3; page++)
        {
            Console.WriteLine($"Loading page {page}");
            await Task.Delay(200);
            yield return new Book("978-0441172719", $"Book from page {page}");
        }
    }
    finally
    {
        Console.WriteLine("Closing the catalog connection"); // runs on break as well
    }
}

record Book(string Isbn, string Title);
Opening the catalog connection
Loading page 1
Found: Book from page 1
Closing the catalog connection
After the loop

Only the first page was loaded, and the moment we left the loop with break, "Closing the catalog connection" was printed. await foreach calls the enumerator's DisposeAsync when the loop ends, when it is left with break, or when an exception is thrown; that in turn runs the finally block in the iterator. If you hold the resource inside the iterator with try/finally or await using, it closes correctly regardless of what the consumer does. If you walk the iterator by hand without await foreach, calling DisposeAsync is your responsibility.

IAsyncDisposable and await using

Closing some resources also takes time: writing a buffer to disk, shutting down a network connection cleanly. Because IDisposable.Dispose is synchronous, it has to do this work by blocking. IAsyncDisposable and await using, which came with C# 8, are the async counterpart:

sealed class OrderExportWriter : IAsyncDisposable
{
    private readonly string _path;
    private readonly List<string> _buffer = new();

    public OrderExportWriter(string path) => _path = path;

    public Task WriteAsync(string row)
    {
        _buffer.Add(row);
        return Task.CompletedTask;
    }

    public async ValueTask DisposeAsync()
    {
        await File.WriteAllLinesAsync(_path, _buffer); // flush the buffer to disk asynchronously
        Console.WriteLine($"Flushed {_buffer.Count} rows to {_path}");
    }
}
await using (OrderExportWriter writer = new("orders.csv"))
{
    await writer.WriteAsync("42,Dune,9.99");
    await writer.WriteAsync("43,Foundation,8.50");
    Console.WriteLine("Rows written");
}
Console.WriteLine("After the using block");
Rows written
Flushed 2 rows to orders.csv
After the using block

When the block ended, the compiler inserted the await writer.DisposeAsync() call; the buffer was written to disk asynchronously, and "After the using block" came only after that. Note the return type of DisposeAsync: ValueTask. Most dispose operations either do not wait at all or finish quickly, the same reasoning again.

There is also a form without braces: await using OrderExportWriter writer = new("orders.csv");. In that case dispose runs when the scope containing the variable ends. A class that will be used both synchronously and asynchronously can implement both; Stream and DbContext do.

In Real Life: JSON and ASP.NET Core

You will meet async streams most often on the JSON side. JsonSerializer.DeserializeAsyncEnumerable, which came with .NET 6, reads a large JSON array element by element without loading it all into memory:

using System.Text;
using System.Text.Json;

byte[] json = Encoding.UTF8.GetBytes(
    "[{\"isbn\":\"978-0441172719\",\"title\":\"Dune\"},{\"isbn\":\"978-0553293357\",\"title\":\"Foundation\"}]");
using MemoryStream stream = new(json); // in reality an HTTP response stream

JsonSerializerOptions options = new(JsonSerializerDefaults.Web);
await foreach (Book? book in JsonSerializer.DeserializeAsyncEnumerable<Book>(stream, options))
{
    Console.WriteLine(book?.Title);
}

record Book(string Isbn, string Title);
Dune
Foundation

In real code you pass the response stream you get from HttpClient instead of a MemoryStream; you start processing a response of thousands of orders as soon as the first element arrives.

It works in the other direction too. In ASP.NET Core an endpoint can return an IAsyncEnumerable<T>:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/books", () => GetBooksAsync());

app.Run();

static async IAsyncEnumerable<Book> GetBooksAsync()
{
    string[] titles = { "Dune", "Foundation", "The Hobbit" };
    foreach (string title in titles)
    {
        await Task.Delay(500); // each book comes from a slow source
        yield return new Book(title);
    }
}

record Book(string Title);

We ran the application, read the response piece by piece with a small client, and printed the arrival time of each piece:

  535 ms  [{"title":"Dune"}
 1037 ms  ,{"title":"Foundation"}
 1537 ms  ,{"title":"The Hobbit"}]

The JSON array did not arrive in one go but piece by piece, as each book became ready. The project targets net6.0; since there is no .NET 6 runtime on this machine, it ran on ASP.NET Core 8 via roll-forward. In EF Core you can also stream the results of a query row by row with AsAsyncEnumerable().

One thing is missing: the .NET 6 BCL has no LINQ operators such as Where, Select or ToListAsync for IAsyncEnumerable<T>. At the time, the community's System.Linq.Async package was used for that. .NET 10 brought these operators into the platform with System.Linq.AsyncEnumerable; we will see it in the .NET 9–10 post of this series.

Common Pitfalls

Not putting [EnumeratorCancellation] on the async iterator's token parameter. WithCancellation silently has no effect; the CS8425 warning says exactly that.

Collecting data that could arrive as a stream with Task<List<T>>. The first element arrives as late as the last one, and they all sit in memory at the same time.

Opening the resource outside the iterator and using it inside. When the consumer leaves early, or never iterates at all, nobody is left to close it. Manage the resource inside the iterator with try/finally or await using.

Closing an async resource with a plain using. On a type that implements only IAsyncDisposable, using does not compile. On a type that implements both, it compiles, but the synchronous Dispose is called and you lose the benefit of the async close.

Iterating the same async iterator twice. Every await foreach runs the method from the start; with a paged API that means requesting all pages a second time. It is the async form of deferred execution in LINQ.

A Small Experiment

★☆☆ While consuming GetBooksAsync from the first example, print only the books whose title starts with "The", and leave with break when you find the second such book. How many pages were requested?

★★☆ Without the System.Linq.Async package, write your own ToListAsync extension method: it should take an IAsyncEnumerable<T>, support a CancellationToken and return a List<T>.

★★★ Rewrite OrderExportWriter with a real StreamWriter and stream orders coming from an async iterator to the file line by line. Throw an exception in the middle of the iteration and check that the file is still closed properly.

In async streams the elements were processed in order, one at a time. Sometimes you want the opposite: processing the work you have on several cores at once, or with limited concurrency. In the next post we look at Parallel.For, Parallel.ForEach, Parallel.ForEachAsync from .NET 6, PLINQ, and when Task.Run is the right tool.