Task<T> represents one value, not yet ready. IAsyncEnumerable<T> represents many values, arriving one at a time, over time.
Everything in this module so far has answered one question: how do I asynchronously get a single result? A customer record, a price, a combined array of pricing/inventory/reviews — always, in the end, one Task producing one value. But what happens when the thing you're producing isn't one value at all, but a whole sequence of them, and each one becomes available only after some asynchronous wait — a page of search results fetched from an API, then another page, then another; or a giant log file being read one line at a time?
You already know IEnumerable<T> from earlier in this course — the contract that lets you foreach over any sequence, one item at a time, via MoveNext(). In this lesson, you'll see exactly why that contract breaks down the moment producing the next item requires an asynchronous wait, and meet its async counterpart: IAsyncEnumerable<T>.
An async stream is a sequence of values that arrive one at a time, where getting the next value might require waiting — for a network response, for more data to be read off disk, for another page of results to come back. IAsyncEnumerable<T> is C#'s type for exactly that: "a sequence of T, where asking for the next item is itself an asynchronous operation."
Recall IEnumerable<T>'s contract from earlier in this course:
public interface IEnumerable<T>
{
IEnumerator<T> GetEnumerator();
}
public interface IEnumerator<T>
{
T Current { get; }
bool MoveNext(); // SYNCHRONOUS — returns immediately, true or false
}IAsyncEnumerable<T> mirrors this shape almost exactly, with one deliberate, crucial change:
public interface IAsyncEnumerable<T>
{
IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}
public interface IAsyncEnumerator<T> : IAsyncDisposable
{
T Current { get; }
ValueTask<bool> MoveNextAsync(); // ASYNCHRONOUS — may need to wait before it knows
}The entire idea is captured in that one line: MoveNext() became MoveNextAsync(). Advancing to the next item is no longer guaranteed to be instant — it's an operation you await, exactly like any other asynchronous call in this module.
IAsyncEnumerable<T> isn't a new idea bolted onto the language — it's the exact same "walk through items one at a time" contract you already know from IEnumerable<T>, with MoveNext() becoming awaitable. Everything you already understand about enumeration carries over directly.
ValueTask<bool> rather than Task<bool> — a performance-oriented Task variant whose full details belong to a later, more advanced lesson. For now, treat it exactly like an awaitable Task<bool>.)Say you're building a method that streams pages of search results from an external API, one page at a time. You have two existing tools, and both are the wrong shape for this job:
MoveNext() must return immediately — no way to await a network call inside it.What's needed is a sequence type where producing each individual item can involve an asynchronous wait, but the caller can still process items as they arrive — starting work on the first item without needing to wait for the last one, and without ever blocking a thread while waiting for the next one to show up.
IAsyncEnumerable<T> fills exactly this gap. On the consuming side, C# gives you a matching keyword: await foreach, which loops over an async stream exactly like an ordinary foreach, except each step to the next item is awaited — freeing the thread while it waits, exactly like every other await in this module, then resuming with the next item once it's actually ready.
await foreach (SearchResult result in SearchProductsStreamAsync(query))
{
Console.WriteLine(result.Name); // process each item as soon as it arrives
}The full mechanics of writing the producing side of this — with async IAsyncEnumerable<T> and yield return — are the next lesson's focus. This lesson's job is the concept and the shape of the problem it solves.
| Shape of the problem | The right tool |
|---|---|
| One value, computed synchronously | Just return T |
| One value, available only after waiting | Task<T> — awaited once |
| Many values, all available synchronously, up front | IEnumerable<T> — foreach |
| Many values, each one available only after waiting | IAsyncEnumerable<T> — await foreach |
Notice the clean progression: going from "one value" to "many values" is the same jump as going from T to IEnumerable<T>. Going from "synchronous" to "asynchronous" is the same jump as going from a plain value to a Task. IAsyncEnumerable<T> is simply what you get when you make both jumps at once.
await foreach (var item in stream)
Process(item);
await using var e = stream.GetAsyncEnumerator();
while (await e.MoveNextAsync())
{
var item = e.Current;
Process(item);
}
Each await e.MoveNextAsync() is a genuine suspension point, exactly like any await you've used throughout this module — the thread is freed while the next item is being produced (waiting on a network call, more disk I/O, whatever the producer needs), and execution resumes with that item once it's ready. await using ensures the enumerator's async cleanup (DisposeAsync() — releasing a connection, closing a file) also happens properly and asynchronously when the loop ends, exactly the way a plain using works for synchronous disposal.
A before/after comparison that makes the difference concrete — fetching three pages of results, "all at once" versus "as an async stream":
// Consuming an async stream — the producing side is next lesson's topic
public async Task PrintFirstMatchingResultAsync(string query)
{
await foreach (SearchResult result in SearchProductsStreamAsync(query))
{
if (result.IsInStock)
{
Console.WriteLine($"Found: {result.Name}");
break; // stops early — later pages are never even fetched
}
}
}Walking through it:
await foreach pulls one SearchResult at a time — each one might have required a fresh page fetch behind the scenes, but the calling code doesn't need to know or care about that detail.break exits the loop — and, crucially, any pages that haven't been fetched yet simply never get fetched. That's impossible to express cleanly with a method that hands back one big, fully-loaded List<SearchResult>.A background worker that processes rows streamed out of a large database export, without ever loading the whole export into memory:
public async Task ProcessOrderExportAsync(IAsyncEnumerable<OrderRecord> exportStream, CancellationToken ct)
{
int processedCount = 0;
await foreach (OrderRecord order in exportStream.WithCancellation(ct))
{
await _shippingQueue.EnqueueAsync(order, ct);
processedCount++;
if (processedCount % 1000 == 0)
_logger.LogInformation("Processed {Count} orders so far...", processedCount);
}
_logger.LogInformation("Export processing complete: {Count} total orders.", processedCount);
}Whether the export holds a hundred orders or ten million, this method's memory footprint stays roughly constant — only the current OrderRecord needs to be in memory at any moment, exactly the same benefit IEnumerable<T> gives you for large synchronous sequences, now available even when producing each record genuinely requires waiting (e.g., reading the next chunk off disk or a paginated source). .WithCancellation(ct) threads a CancellationToken into the stream itself — the deep mechanics of this are covered fully in the next lesson.
Task<List<T>> is like a buffet: the kitchen won't let you in until every single dish is fully prepared and laid out. You get everything at once, but you wait for the slowest dish before you can eat anything — even the dish that was ready twenty minutes ago.
IAsyncEnumerable<T> is like a proper multi-course meal: the kitchen brings out each course the moment it's ready, and you start eating the appetizer while the main course is still being prepared. If you decide you're full after the second course, the kitchen never bothers finishing the third — nothing wasted. That's exactly the "process items as they arrive, and stop early if you want to" benefit async streams give you over waiting for one giant, fully-loaded collection.
At a high level, IAsyncEnumerable<T> combines two compiler transformations you've already met separately in this course: the iterator machinery behind yield return (which lets a method pause and resume, remembering where it left off, to produce one item at a time) and the async state machine behind async/await (which lets a method pause and resume around an asynchronous wait, freeing the thread in the meantime). A method written as async IAsyncEnumerable<T> gets both: it can pause to yield return a value and pause to await something, in any combination, and the compiler generates a single state machine capable of both kinds of suspension. The full mechanics of writing one of these methods yourself are exactly what the next lesson covers.
An async stream doesn't pre-fetch every item ahead of time on its own. Each item is typically produced exactly when MoveNextAsync() is called for it — not before. This is precisely what allows the "stop early and skip the rest" benefit from the earlier example: nothing has been fetched yet for items you never asked for.
Those two lessons were about coordinating several separate Tasks that were all started up front. Async streams are about a single ongoing sequence of values, produced incrementally over time — a fundamentally different shape of problem, even though both live under the "asynchronous programming" umbrella.
Buffering an entire large or open-ended sequence (a huge export, a live feed) into one List<T> before returning it — high memory use, and the caller waits for everything before seeing anything.
If the caller can meaningfully process items one at a time, and producing each one may involve waiting, IAsyncEnumerable<T> is usually the better fit.
This won't even compile — a plain foreach expects synchronous IEnumerable<T>/MoveNext(), and IAsyncEnumerable<T> only offers MoveNextAsync().
Use await foreach specifically — and make sure the enclosing method is itself async, since await foreach awaits internally.
await foreach — never a plain foreach.IAsyncEnumerable<T> is the async counterpart of IEnumerable<T> — the same "walk through items one at a time" contract, with MoveNext() becoming awaitable MoveNextAsync().await foreach, which awaits each step to the next item, freeing the thread while it waits — exactly like any other await.IEnumerable<T> (can't await inside MoveNext) nor a single Task<List<T>> (forces waiting for everything) fits a sequence produced incrementally over time.You've seen why a new sequence type was needed for asynchronously-produced items. Let's check the core distinction is clear.
1. What is the fundamental limitation of IEnumerable<T> that IAsyncEnumerable<T> was created to solve?
Correct: B
Why B is correct: IEnumerator<T>.MoveNext() is a synchronous method — it must return true or false immediately. There's no way to await something inside it without blocking the thread, which is exactly the problem asynchronous programming exists to avoid.
Why A is incorrect: IEnumerable<T> can represent sequences of any size, including unbounded ones — size isn't the limitation here.
Why C is incorrect: IEnumerable<T> is precisely what powers plain foreach — the limitation is about asynchronous production of items, not about foreach support itself.
Why D is incorrect: IEnumerable<T> works with any type implementing it, not just arrays — that's the whole point of the interface.
Reinforcement: The synchronous nature of MoveNext() is exactly the gap IAsyncEnumerable<T> closes with its awaitable MoveNextAsync().
2. A method needs to stream a million rows from a database export, processing each one as it arrives, without holding all of them in memory at once. Which return type best fits this need?
Correct: B
Why B is correct: IAsyncEnumerable<OrderRecord> lets each row be produced (potentially with an asynchronous wait) and consumed one at a time via await foreach, keeping memory use roughly constant regardless of how many rows there are.
Why A is incorrect: This would force loading all one million rows into memory before the caller can process even the first one — exactly the problem being avoided.
Why C is incorrect: A plain array has the same "everything must exist up front, all in memory" problem as the List<T> option, with no asynchrony at all.
Why D is incorrect: Task<OrderRecord> represents exactly one value, not a sequence of a million of them.
Reinforcement: IAsyncEnumerable<T> is specifically the tool for large or open-ended sequences produced incrementally.
3. What is the key difference between the plain foreach keyword and await foreach?
Correct: B
Why B is correct: await foreach is specifically for IAsyncEnumerable<T>, where advancing to the next item is an asynchronous operation. It awaits MoveNextAsync() at each step, suspending the method and freeing the thread while the next item is being produced — exactly like any other await in this module.
Why A is incorrect: There's no inherent multithreading involved — the benefit is not blocking the thread while waiting, not parallel execution.
Why C is incorrect: await foreach works with any IAsyncEnumerable<T>, which arrays don't even implement.
Why D is incorrect: They target genuinely different interfaces (IEnumerable<T> vs IAsyncEnumerable<T>) with a real behavioral difference — synchronous vs. awaitable advancement.
Reinforcement: await foreach is the consuming-side counterpart to IAsyncEnumerable<T>, exactly as foreach is to IEnumerable<T>.
4. A caller uses await foreach over an async stream of search results and calls break after finding the first in-stock item, on page 2 of what could have been 10 pages. What happens to pages 3 through 10?
Correct: B
Why B is correct: Async streams typically produce each item (or page) only when it's actually requested via MoveNextAsync — not ahead of time. Breaking out of the loop early means later items were never asked for, so that work is simply never done.
Why A is incorrect: Nothing is pre-fetched automatically in the background — that would defeat the memory and efficiency benefits of streaming in the first place.
Why C is incorrect: There's no such automatic caching behavior — a stream that's stopped early simply stops producing further items.
Why D is incorrect: Breaking out of an await foreach loop early is completely normal and doesn't throw any exception — the enumerator is disposed cleanly (via await using under the hood).
Reinforcement: On-demand production is one of the biggest practical advantages of async streams over pre-loading everything into a single collection.
You now understand what problem async streams solve and how they're consumed. Next up: writing the producing side yourself — async IAsyncEnumerable<T> and yield return, with real paginated-API and file-reading examples.
dotnetmadeeasy.com — Learn C# and .NET, the right way.