Async iterators with yield return and await, await foreach, EnumeratorCancellation, ConfigureAwait, LINQ over async streams in .NET 10, EF Core and ASP.NET Core streaming.
IAsyncEnumerable<T> is a sequence whose items arrive asynchronously. You produce one with an async iterator method that uses both await and yield return, and consume it with await foreach, which awaits MoveNextAsync() for each item and disposes the enumerator asynchronously. Cancellation is passed with .WithCancellation(ct) and received in the iterator through a parameter marked [EnumeratorCancellation]. It lets you process large or slow data item by item instead of buffering everything, for example EF Core AsAsyncEnumerable() or streaming JSON responses from ASP.NET Core. .NET 10 includes LINQ operators for it in the base library (System.Linq.AsyncEnumerable).
"
IAsyncEnumerable<T>is a sequence whose items arrive asynchronously: the async counterpart ofIEnumerable<T>. Its enumerator'sMoveNextAsync()returnsValueTask<bool>, and it'sIAsyncDisposable. I produce one with anasynciterator, a method that uses bothawaitandyield return, and consume it withawait foreach, which awaits each item and disposes the enumerator asynchronously, including when Ibreakearly. Cancellation flows through.WithCancellation(ct)on the consumer side into an iterator parameter marked[EnumeratorCancellation]. Without that attribute the token is silently ignored. The point of async streams is to process data as it arrives: flat memory instead of buffering millions of rows, a faster first result, and natural backpressure, because the producer only advances when the consumer asks for the next item. You meet them in EF Core'sAsAsyncEnumerable(), ASP.NET Core streaming responses, gRPC streaming,Channel<T>.Reader.ReadAllAsync()and Azure SDK paging. And since .NET 10, LINQ over async streams is built into the base library."
| Type | Members |
|---|---|
IAsyncEnumerable<T> |
GetAsyncEnumerator(CancellationToken = default) |
IAsyncEnumerator<T> |
ValueTask<bool> MoveNextAsync(), T Current, ValueTask DisposeAsync() |
public async IAsyncEnumerable<Order> ReadOrdersAsync([EnumeratorCancellation] CancellationToken ct = default)
{
string? continuation = null;
do
{
var page = await _api.GetPageAsync(continuation, ct);
foreach (var order in page.Items) yield return order;
continuation = page.Next;
}
while (continuation is not null);
}
| Rule | Detail |
|---|---|
| Lazy | Nothing runs until enumeration starts |
| Re-run per enumeration | Each await foreach executes the iterator again |
try/finally and await using inside |
Run when the loop ends, breaks, throws or is cancelled |
yield not allowed in catch/finally |
Same as sync iterators |
await foreach (var order in client.ReadOrdersAsync().WithCancellation(ct).ConfigureAwait(false))
{
await ProcessAsync(order, ct);
if (order.IsLast) break; // DisposeAsync runs -> iterator's finally runs
}
| Approach | Memory | Time to first item | Backpressure |
|---|---|---|---|
Task<List<T>> (buffer everything) |
Grows with data size | After the last item | None |
IAsyncEnumerable<T> |
One item (or page) at a time | After the first item | Producer waits for the consumer |
Channel<T> |
Bounded buffer | First item | Configurable (bounded channels) |
| Technology | Usage |
|---|---|
| EF Core | query.AsNoTracking().AsAsyncEnumerable() streams rows from the reader |
| ASP.NET Core | Returning IAsyncEnumerable<T> streams a JSON array via System.Text.Json without buffering |
| gRPC | Server/client streaming calls |
| SignalR | Streaming hub methods |
| Channels | reader.ReadAllAsync(ct) |
| Azure SDK | AsyncPageable<T> |
| LINQ | .NET 10: System.Linq.AsyncEnumerable (Where, Select, ToListAsync, ...) in the box; earlier: the System.Linq.Async NuGet package |
| Need | Choose |
|---|---|
| Pull items one at a time from an async source | IAsyncEnumerable<T> |
| Many producers/consumers, buffering, decoupled speeds | Channel<T> |
| You need the whole result anyway (sorting, random access) | Task<List<T>> |
| CPU-heavy multi-stage processing | TPL Dataflow / Parallel.ForEachAsync |
Our [reporting service] exported up to [2 million] rows to CSV by loading them into a List<T> with ToListAsync(), building the CSV, then writing it. Large exports spiked memory to [about 3 GB], pods were OOM-killed, and the gateway timed out before the first byte. I changed the query to AsNoTracking().AsAsyncEnumerable() and wrote each row to the response body through a StreamWriter, flushing every [1,000] rows and honouring the request's cancellation token, so abandoned downloads stopped the query. Memory stayed under [100 MB] regardless of export size, downloads started immediately, and we added an export load test with a memory limit to CI.
Checks modern async data handling.
Green flags
await foreach loop.[EnumeratorCancellation].break.Red flags
Task<IEnumerable<T>> and calls it streaming.An async iterator compiles to a class that implements IAsyncEnumerable<T>, IAsyncEnumerator<T>, IAsyncDisposable and IValueTaskSource<bool>:
MoveNextAsync():
resume state machine until:
yield return x -> Current = x; complete the ValueTask<bool> with true
await (incomplete) -> return pending ValueTask<bool> (backed by ManualResetValueTaskSourceCore)
end of method -> complete with false
DisposeAsync():
run pending finally blocks / await using disposals
Because MoveNextAsync returns a reusable ValueTask<bool>, streaming items doesn't allocate a task per item.
// Consumer
await foreach (var x in source.WithCancellation(ct)) { ... }
// What happens
source.GetAsyncEnumerator(ct) -> compiler routes ct into the [EnumeratorCancellation] parameter
| Iterator parameter | Consumer passes | Effective token |
|---|---|---|
[EnumeratorCancellation] CancellationToken ct |
ReadAsync(a) |
a |
| same | ReadAsync().WithCancellation(b) |
b |
| same | ReadAsync(a).WithCancellation(b) |
Linked a + b |
| No attribute | .WithCancellation(b) |
Ignored (warning CS8425) |
app.MapGet("/orders", (OrdersDb db, CancellationToken ct) =>
db.Orders.AsNoTracking().OrderBy(o => o.Id).AsAsyncEnumerable());
System.Text.Json writes the array incrementally, flushing as it goes. Things to know:
AsNoTracking() to avoid the change tracker growing with each row.app.MapGet("/export", async (HttpContext http, OrdersDb db, CancellationToken ct) =>
{
http.Response.ContentType = "text/csv";
await using var writer = new StreamWriter(http.Response.Body);
int n = 0;
await foreach (var o in db.Orders.AsNoTracking().AsAsyncEnumerable().WithCancellation(ct))
{
await writer.WriteLineAsync($"{o.Id},{o.Total}");
if (++n % 1000 == 0) await writer.FlushAsync(ct);
}
});
| Pitfall | Effect |
|---|---|
| Enumerating twice | Runs the query/API calls twice |
Two concurrent enumerations on one DbContext |
InvalidOperationException (DbContext isn't thread-safe) |
| Exceptions | Surface from MoveNextAsync mid-loop: handle around the await foreach |
| Slow consumer | Holds source resources (connections, cursors) open |
ToListAsync() on huge streams |
Recreates the buffering problem |
| Version | Change |
|---|---|
| C# 8 / .NET Core 3.0 | IAsyncEnumerable<T>, async iterators, await foreach, [EnumeratorCancellation] |
| .NET 6 | ASP.NET Core streams IAsyncEnumerable<T> JSON responses without buffering |
| EF Core 3.0+ | AsAsyncEnumerable() |
| .NET 10 | System.Linq.AsyncEnumerable LINQ operators in the base library |
finally message.[EnumeratorCancellation] and read warning CS8425; confirm the timeout no longer stops the feed.IAsyncEnumerable<T> from a minimal API and watch the response arrive progressively with curl -N.ToListAsync() vs AsAsyncEnumerable() over 1 million rows using dotnet-counters.Where/Select/ToListAsync on an async stream without extra packages.using System.Runtime.CompilerServices;
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
await foreach (var reading in SensorFeed.ReadAsync("pump-7").WithCancellation(cts.Token))
{
Console.WriteLine($"{reading.At:HH:mm:ss.fff} {reading.Value}");
if (reading.Value > 95) break; // disposes the enumerator, runs the iterator's finally
}
// .NET 10: LINQ over IAsyncEnumerable<T> is built in (System.Linq.AsyncEnumerable).
var highs = await SensorFeed.ReadAsync("pump-8")
.Where(r => r.Value > 50)
.Take(3)
.ToListAsync();
Console.WriteLine($"High readings: {highs.Count}");
public sealed record Reading(DateTime At, int Value);
public static class SensorFeed
{
public static async IAsyncEnumerable<Reading> ReadAsync(
string sensorId,
[EnumeratorCancellation] CancellationToken ct = default)
{
var random = new Random(sensorId.GetHashCode());
try
{
while (true)
{
await Task.Delay(100, ct); // simulate waiting for the next reading
yield return new Reading(DateTime.UtcNow, random.Next(0, 100));
}
}
finally
{
Console.WriteLine($"Feed {sensorId} closed"); // runs on break, cancellation or error
}
}
}
[EnumeratorCancellation]What happens: the iterator has a CancellationToken parameter without the attribute.
Why it is wrong: tokens passed with WithCancellation are silently ignored (CS8425).
Do instead: mark the parameter; consumers use WithCancellation(ct).
ToListAsync() on huge streamsWhat happens: "just materialise it, it's easier". Why it is wrong: brings back memory spikes and slow first results. Do instead: process in the loop or in bounded batches.
What happens: a stream is counted, then iterated. Why it is wrong: each enumeration reruns the iterator (and its queries). Do instead: materialise once if multiple passes are needed.
What happens: each row triggers an HTTP call while streaming from SQL. Why it is wrong: the connection is held for the whole export; pool pressure. Do instead: keep per-item work light, or buffer in batches and release the reader.
Task<IEnumerable<T>> and calling it streamingWhat happens: the method buffers everything then returns.
Why it is wrong: no streaming benefits.
Do instead: return IAsyncEnumerable<T>.
What happens: streaming EF queries without AsNoTracking().
Why it is wrong: the change tracker grows with every row.
Do instead: AsNoTracking() for read-only streams.
DbContextWhat happens: two streams from the same context are enumerated in parallel.
Why it is wrong: DbContext isn't thread-safe → exceptions.
Do instead: one context per concurrent operation.
What happens: an exception after the first item. Why it is wrong: headers already sent; the client sees a truncated body. Do instead: validate up front; design clients to detect incomplete streams.
MoveNextAsync without disposingWhat happens: custom enumeration code skips DisposeAsync.
Why it is wrong: the iterator's finally/await using never run → leaked connections.
Do instead: await foreach, or await using var e = source.GetAsyncEnumerator(ct).
What happens: merging multiple producers through iterators.
Why it is wrong: awkward and serialising.
Do instead: a Channel<T> with multiple writers.
Async streams make streaming the default shape for large data. I use them at I/O boundaries (database, paging APIs, HTTP responses) and keep an eye on resource lifetime, because a stream holds its source open until the consumer finishes.
| Scenario | Pattern |
|---|---|
| Large DB export | EF Core AsAsyncEnumerable() + streamed response |
| Paged REST API | Async iterator over pages |
| Live feed | Async iterator or Channel<T>.Reader.ReadAllAsync() |
| Need random access | Materialise to a list |
A method that uses yield return is an iterator, and the compiler rewrites it into a state machine class that implements IEnumerable<T> and IEnumerator<T>. Locals become fields, a state field remembers where it paused, and each MoveNext runs to the next yield return. yield break ends the sequence. Execution is lazy, and finally and using blocks run when iteration finishes or the consumer disposes early. Iterators can't have ref or out parameters or yield inside a catch, and async iterators add await. They're ideal for streaming and large or infinite sequences.
System.Threading.Channels provides fast, async-friendly queues for passing data between producers and consumers. Channel.CreateBounded<T>(capacity) gives backpressure: when full, WriteAsync waits (or drops items, depending on BoundedChannelFullMode). Channel.CreateUnbounded<T>() never blocks writers but can grow without limit. Writers call Complete() when done; readers loop with await foreach (var x in reader.ReadAllAsync(ct)) and finish when the channel is completed and empty. Options such as SingleReader and SingleWriter enable faster internal implementations. Channels are the standard building block for in-process background queues and pipelines.
Implement IAsyncDisposable when cleanup involves asynchronous I/O, such as flushing a buffered stream, sending a final message or closing a network connection gracefully. DisposeAsync() returns ValueTask. await using var x = ...; compiles to try/finally with await x.DisposeAsync(), and you can write await using (x.ConfigureAwait(false)) in library code. A type that supports both should implement IDisposable and IAsyncDisposable, put async cleanup in protected virtual ValueTask DisposeAsyncCore(), and call GC.SuppressFinalize. In DI, use CreateAsyncScope(): disposing a provider or scope synchronously throws when it holds a service that implements only IAsyncDisposable.
How to tell it (STAR)
Our [large exports] were crashing pods because everything was buffered. I changed the query to an async stream and wrote rows straight to the response, with cancellation from the request. Memory stayed under [100 MB] and downloads began immediately.
Last updated 31 Oct 2026.