Async Streams in C#: IAsyncEnumerable and await foreach
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>.
What Is It?
The Simple Explanation
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."
The Technical Definition
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>.)Why Does It Exist?
The Problem — Neither Existing Tool Fits
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:
IEnumerable<T> — synchronous only
MoveNext()must return immediately — no way to await a network call inside it.- You'd have to block the thread to fetch the next page, exactly the problem this whole module exists to avoid.
Task<List<T>> — all-or-nothing
- Forces you to wait for every page before returning anything at all.
- The caller can't start processing the first result the moment it arrives — everything is buffered in memory until the very end.
The Need
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.
The Solution — IAsyncEnumerable<T> and await foreach
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.
Big Picture
| 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.
How It Works
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.
Simple Example
A before/after comparison that makes the difference concrete — fetching three pages of results, "all at once" versus "as an async stream":
BEFORE — Task<List<T>>
- Caller must wait for ALL 3 pages before seeing anything
- Every page is buffered in memory at once
- If the caller only needed the first result, all 3 pages were fetched anyway
AFTER — IAsyncEnumerable<T>
- Caller sees page 1's items the moment they arrive
- Only the current item needs to be in memory
- Caller can stop early (e.g., break) without fetching remaining pages
// 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 foreachpulls oneSearchResultat 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.- The moment a matching item is found,
breakexits 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-loadedList<SearchResult>.
Real-World Example
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.
Analogy
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.
Under the Hood
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.
Common Confusion
1. "IAsyncEnumerable<T> means everything is loaded in the background while I do other stuff" — not automatically
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.
2. This isn't the same thing as Task.WhenAll or Task.WhenAny
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.
Common Mistakes
Mistake 1 — Reaching for Task<List<T>> out of habit when the sequence could be huge or unbounded
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.
Mistake 2 — Using a plain foreach on an IAsyncEnumerable<T>
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.
When Should I Use It?
Mental Model
Task<T> = "one value, not ready yet — await it."
IAsyncEnumerable<T> = "many values, and getting each next one might mean waiting."
Remember:
· Same contract as IEnumerable<T>, with MoveNext() becoming an awaitable MoveNextAsync().
· Consume it with
await foreach — never a plain foreach.· Items are produced incrementally, on demand — stopping early skips the rest of the work.
Key Takeaway
- An async stream is a sequence of values where producing the next one may require an asynchronous wait.
IAsyncEnumerable<T>is the async counterpart ofIEnumerable<T>— the same "walk through items one at a time" contract, withMoveNext()becoming awaitableMoveNextAsync().- Consume it with
await foreach, which awaits each step to the next item, freeing the thread while it waits — exactly like any otherawait. - It solves a real gap: neither synchronous
IEnumerable<T>(can't await insideMoveNext) nor a singleTask<List<T>>(forces waiting for everything) fits a sequence produced incrementally over time. - Items are produced on demand — a consumer that stops early never pays for the rest.
Check Your Understanding
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?
Show answer
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?
Show answer
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?
Show answer
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?
Show answer
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.