AlgoMaster Logo

IAsyncEnumerable<T>

Medium Priority15 min readUpdated June 6, 2026

A Task<List<T>> forces the whole sequence to materialize before any consumer can touch it. If a server returns a million matching products page by page, the caller waits for every page to land in memory before the first item shows up. IAsyncEnumerable<T> fixes that by giving back an asynchronous stream: each item becomes available the moment it arrives, and the consumer iterates with await foreach. This lesson covers what IAsyncEnumerable<T> is, how to produce one with async iterator methods, how to consume it, and how cancellation and ConfigureAwait work on async streams.

The Problem With Task<List<T>>

Task<List<T>> is the default way to return many items from an async method. It's a single promise that, when complete, hands back the whole collection.

The caller sees nothing for roughly one second, then gets every result at once. The first page was actually ready at 200 ms, but the method couldn't return it until pages two through five also arrived. For a paged product search, server-sent events, or a long file read, that "wait for everything" model is wrong. The caller often wants to display, process, or filter items as they appear.

IEnumerable<Task<T>> looks like an alternative because it's a sequence whose elements are tasks. It doesn't really solve the problem either. The producer still has to decide up front how many tasks to start, and the consumer has to drive the iteration with regular foreach and await on each task, which mixes synchronous and asynchronous iteration in awkward ways. Cancellation and back-pressure also become the consumer's problem.

IAsyncEnumerable<T> is the right shape for "a sequence of values, each one arriving asynchronously."

What IAsyncEnumerable<T> Is

IAsyncEnumerable<T> shipped in C# 8 / .NET Core 3.0. It's the asynchronous counterpart of IEnumerable<T>. The interface has one method, GetAsyncEnumerator, which returns an IAsyncEnumerator<T>. The enumerator has MoveNextAsync() returning ValueTask<bool> and a Current property of type T, plus DisposeAsync for cleanup.

You almost never call these methods directly. The compiler does it for you when you write await foreach. The same way foreach over an IEnumerable<T> desugars into GetEnumerator / MoveNext / Current, await foreach over an IAsyncEnumerable<T> desugars into GetAsyncEnumerator / MoveNextAsync / Current plus an awaited DisposeAsync at the end.

The diagram below contrasts the two execution models. The top track buffers everything, then hands it over. The bottom track streams items as they're produced.

The streaming track isn't faster end to end. The last page still arrives at 1000 ms in both models. What changes is when the consumer sees the first item, when it can start work, and how much memory the producer holds at once. Streaming pushes one item at a time, so the producer's working set is one page, not five.

Producing With async + yield return

You don't implement IAsyncEnumerable<T> by hand for most cases. The compiler builds the state machine for you when you write an async iterator method, which is any method that combines async, returns IAsyncEnumerable<T>, and uses yield return to push values.

The shape mirrors a regular iterator method. The differences are the async modifier on the signature, the IAsyncEnumerable<T> return type instead of IEnumerable<T>, and the freedom to await between yield return statements. Each yield return suspends the method until the consumer calls MoveNextAsync again, and the await on the consumer side resumes the producer.

A real version would do the actual page fetch over HTTP instead of Task.Delay. The shape stays the same.

Two new things to flag. yield break ends the stream cleanly; the consumer's await foreach simply exits. And the producer drives pagination internally, so the consumer never sees pages, only individual products.

The compiler enforces one rule: you can't put await inside a try block whose catch or finally follows a yield return in the older state-machine layout. Modern C# (8+) lifts that restriction in async iterators, and you can mix try / catch with yield return naturally. The one thing you can't do is yield return inside a catch block. That's a compile error, CS1631.

Each await between yield returns involves a state-machine transition. The cost is small but real. For tight, CPU-bound sequences that don't need async work between items, IEnumerable<T> with a normal yield return stays cheaper. Use IAsyncEnumerable<T> only when the producer needs to await between items, like waiting on I/O or a timer.

Consuming With await foreach

await foreach is the consumer-side counterpart of an async iterator. It walks an IAsyncEnumerable<T>, awaiting each MoveNextAsync call and binding Current to the loop variable.

The loop body runs once per item, in order, and only after the previous item finished processing. That ordering is the consumer's contract: even though the producer is async, items are delivered sequentially, never in parallel. If you want parallel consumption, you have to fan out explicitly, the loop itself stays one-at-a-time.

You can use any normal control flow inside the loop. break exits early. return from the surrounding method works. continue skips to the next iteration. Exceptions thrown inside the loop propagate out the way they would from a regular foreach.

The compiler also requires that the enclosing method be async. You can't await foreach from a synchronous context any more than you can await from one. If you need to bridge to synchronous code, you'd block on .GetAwaiter().GetResult() at the outer call site, but that re-introduces the original blocking problem and defeats the purpose.

One detail to know. The expression after in is evaluated only once, when the loop starts. The producer method (StreamAsync() above) is invoked at that single point, returns an IAsyncEnumerable<T>, and the loop iterates over that one stream. If you write await foreach (var x in source.StreamAsync()) and want a fresh stream on retry, you have to call the method again to get a new enumerable.

Cancellation: EnumeratorCancellation and WithCancellation

Async work needs cancellation. For a Task-returning method you accept a CancellationToken parameter and pass it down to await calls. For an async iterator the pattern looks similar but has one extra piece: the [EnumeratorCancellation] attribute.

The reason is that the consumer can pass its own cancellation token via WithCancellation at the call site, separate from any token passed when the method was invoked. The compiler needs to know which parameter on the iterator method should receive that consumer-supplied token. [EnumeratorCancellation] is the marker.

.WithCancellation(cts.Token) is the consumer's way to pipe a token into the enumerator. Without [EnumeratorCancellation] on the parameter, the iterator method would receive default for cancellationToken, even though the consumer supplied one. The attribute tells the compiler "this is the parameter the consumer's token flows into."

The behavior to remember: cancellation flips the iterator's awaits to throw OperationCanceledException, which propagates out of await foreach. The producer's try / catch / finally blocks still run for cleanup. Anything the producer holds (database connections, file handles) gets disposed if you use using or await using inside the iterator.

If both the caller passes a token to the method directly and the consumer uses WithCancellation, the iterator sees a token that's linked from both. Cancelling either one cancels the stream. This is how IAsyncEnumerable<T> composes through multiple layers: each layer can attach its own cancellation without the inner ones losing theirs.

The token reaches three places: an explicit ThrowIfCancellationRequested() check between pages, the HttpClient call so a cancel aborts the in-flight HTTP request, and implicit propagation if the consumer uses WithCancellation. Skipping any of those leaves a window where cancellation requests are ignored.

ConfigureAwait on Async Streams

Synchronous-looking awaits in async methods carry a synchronization context by default. In a UI application, that means the continuation runs back on the UI thread. In a library, it's usually unnecessary overhead, and the standard fix on a single await is .ConfigureAwait(false).

await foreach follows the same rule. The continuation between items defaults to the captured context. To opt out, you call .ConfigureAwait(false) on the IAsyncEnumerable<T> itself before the await foreach.

The method call chain reads "give me the stream, configure its awaits not to capture context, then iterate." The extension method ConfigureAwait(false) on IAsyncEnumerable<T> returns a ConfiguredCancelableAsyncEnumerable<T>, which await foreach knows how to consume. The same wrapper supports cancellation too: .WithCancellation(token).ConfigureAwait(false) and .ConfigureAwait(false).WithCancellation(token) both work and return the same effective configuration.

The rule of thumb mirrors regular await. Inside library code or any code that doesn't need to return to a UI/synchronization context, use .ConfigureAwait(false) on every await foreach. Inside application code that does need to come back to the captured context (a Windows Forms event handler, for instance), leave it off. The default still works correctly, it's just slower because it queues continuations through the captured context's scheduler.

Each item in await foreach involves at least one await, so a 10,000-item stream queues 10,000 continuations through whatever scheduler is captured. With a UI synchronization context, that's 10,000 marshalling calls back to the UI thread. ConfigureAwait(false) skips that work entirely and runs the continuation on whatever thread the producer's await finished on.

When to Choose IAsyncEnumerable<T>

IAsyncEnumerable<T> isn't always the right return type. Task<List<T>> is still the right answer when the consumer genuinely needs the whole list before doing anything, or when the underlying source materializes all results at once anyway. The decision comes down to whether streaming actually changes how the consumer works.

Return typeProducer behaviorConsumer codeBest for
Task<List<T>>Waits for all items, returns oncevar list = await Method(); foreach (...)Small, bounded results the caller needs in full (cart contents, user profile, single page of results)
IAsyncEnumerable<T>Yields one item at a time asynchronouslyawait foreach (var x in Method())Streaming sources (paged API, server-sent events, file lines, query that returns many rows)
IEnumerable<Task<T>>Returns a sequence of pending tasksforeach (var t in Method()) await tFan-out where the caller wants to control concurrency manually (rarely the best fit)

A concrete example. Fetching a single order's details is a Task<Order>. Fetching the line items of one order is usually a Task<List<OrderItem>> because there are only a handful and the caller needs them all to compute a total. Searching all products matching a query, where there might be a million matches and the caller wants to display them as they arrive, is IAsyncEnumerable<Product>. Same goes for processing an order-events stream that runs indefinitely.

Two anti-patterns worth flagging. First, returning IAsyncEnumerable<T> and immediately calling .ToListAsync() at every call site cancels the benefit; you're back to Task<List<T>> with extra ceremony. Pick the shape that matches how callers will actually use it. Second, returning Task<IEnumerable<T>> is a common confusion. That's "a task that completes with a synchronous sequence," not an async stream, and it has the same materialize-first problem as Task<List<T>>.

For producer-consumer scenarios with explicit back-pressure, where the producer needs to slow down when the consumer falls behind, channels (System.Threading.Channels.Channel<T>) are usually the better fit.

LINQ on Async Streams

Standard LINQ operators (Where, Select, OrderBy, Count, and so on) target IEnumerable<T>. They don't work on IAsyncEnumerable<T> because the source's iteration is async, and a synchronous predicate doesn't know how to wait between items.

Microsoft ships a separate package, System.Linq.Async, that adds async-aware versions of every common LINQ operator. Once the package is referenced, you get operators like WhereAsync, Where (synchronous predicate), WhereAwait (async predicate), SelectAwait, CountAsync, ToListAsync, and many more, all working over IAsyncEnumerable<T>.

The synchronous overloads (Where, Select) take regular lambdas and stay async-aware about the source. The Await overloads (WhereAwait, SelectAwait) accept Func<T, ValueTask<TResult>> lambdas, so the predicate or projection itself can do async work. Use the async overloads only when needed; an async lambda for Where adds per-item overhead even if it doesn't actually await anything.

ToListAsync() materializes the whole stream into a List<T> and is the right escape hatch when you do need everything at once. It's identical in effect to writing a manual await foreach that adds items to a list. The point of IAsyncEnumerable<T> is not to ban materializing, it's to make materializing a choice the consumer makes rather than a behavior baked into the producer.

Quiz

IAsyncEnumerable Quiz

10 quizzes