yield return said "here's the next item." async IAsyncEnumerable<T> lets you also say "...but first, let me go fetch it."
The last lesson showed you the consuming side of async streams — await foreach pulling items out of an IAsyncEnumerable<T>, one at a time, as they became ready. But something had to produce SearchProductsStreamAsync(query) in the first place. Where do those items actually come from?
You already know how to produce a plain synchronous sequence: write a method that returns IEnumerable<T> and use yield return inside it. In this lesson, you'll learn the async counterpart — writing a method as async IAsyncEnumerable<T> and using yield return alongside real await calls — and see it solve two genuinely real problems: streaming paginated API results as they arrive, and reading a large file line by line without loading the whole thing into memory.
An async IAsyncEnumerable<T> method is a method that produces items one at a time using yield return — exactly like a normal iterator method — except it's also allowed to await real asynchronous work in between yields. Fetch a page, yield its items, fetch the next page, yield those, and so on, until there's nothing left.
A method is recognized by the compiler as an async iterator when it satisfies three conditions together: it's marked async, its return type is IAsyncEnumerable<T> (or IAsyncEnumerator<T>), and its body contains at least one yield return. Meeting all three tells the compiler to generate the combined state machine described in the previous lesson's "Under the Hood" section — one capable of suspending both on await and on yield return.
public async IAsyncEnumerable<SearchResult> SearchProductsStreamAsync(string query)
{
int page = 1;
bool hasMorePages = true;
while (hasMorePages)
{
SearchPage response = await _httpClient
.GetFromJsonAsync<SearchPage>($"/search?q={query}&page={page}");
foreach (SearchResult result in response.Items)
yield return result; // hand one item back to the caller
hasMorePages = response.HasNextPage;
page++;
}
}Inside this one method body, await pauses to wait for something (a network call), and yield return pauses to hand a value to the caller. They can appear in any order, any number of times — the compiler's combined state machine tracks both kinds of suspension in the same method.
A plain synchronous iterator method (IEnumerable<T> with yield return) is not marked async, and cannot contain await at all — the compiler rejects it outright. So if the next item genuinely requires an asynchronous wait (fetching page 2 from an API, reading the next chunk of a file), there was, before this feature, no clean way to write that as an iterator. You'd either have to block synchronously inside MoveNext() (defeating the entire point of async), or abandon the elegant yield return syntax and hand-roll something far messier.
What's needed is a way to write a producer method that reads exactly like a familiar synchronous iterator — a loop, some logic, yield return — but is also free to await whatever asynchronous work is needed to produce the next value.
That's exactly what async IAsyncEnumerable<T> gives you. The method above reads almost identically to how you'd write a synchronous paginated loop that returned IEnumerable<SearchResult> — the only additions are the async modifier, the IAsyncEnumerable<T> return type, and an await in front of the network call. Everything else — the loop, the yield return, the pagination logic — is the same shape you already know.
| Producing side | Consuming side | What it takes to write it |
|---|---|---|
IEnumerable<T> + yield return | foreach | No await allowed inside |
Task<T> (no yield) | await once | One value, computed with await inside |
async IAsyncEnumerable<T> + yield return | await foreach | Many values, each producible with await inside |
This lesson's method is the last row — the piece that was missing until now. It's the producing-side twin of the consuming-side await foreach you already know from the previous lesson.
await — the thread is freed while page 1 is fetched.MoveNextAsync() from Step 1 complete, with Current set to that item.MoveNextAsync() again — execution resumes in the producer right after the yield return that paused it, not from the top.hasMorePages is false and the method runs off the end — that's the signal that tells await foreach the sequence is done.Notice the key detail: fetching page 2 doesn't happen until the caller has actually asked for the item right after the last one from page 1. Nothing is pre-fetched — production is driven entirely by the consumer's demand, exactly as the previous lesson described.
Before writing anything paginated or file-based, here's the smallest possible async iterator — a countdown that waits a bit between each number, to make the "await, then yield, repeat" shape unmistakable:
public async IAsyncEnumerable<int> CountDownAsync(int from)
{
for (int i = from; i > 0; i--)
{
await Task.Delay(500); // pretend this is "waiting for the next value to be ready"
yield return i; // hand this value to the caller
}
}
// Consuming it:
await foreach (int number in CountDownAsync(3))
{
Console.WriteLine(number); // prints 3, 2, 1 — roughly half a second apart
}Walking through it:
async IAsyncEnumerable<int> — all three markers from the technical definition are present: async, that return type, and a yield return in the body.await foreach on the caller's side needs no special handling for the fact that this producer both awaits and yields — it's completely transparent from the outside.The full, cancellation-aware version of the search example from earlier — a realistic shape for calling any paginated third-party API:
public async IAsyncEnumerable<SearchResult> SearchProductsStreamAsync(
string query,
[EnumeratorCancellation] CancellationToken ct = default)
{
int page = 1;
bool hasMorePages = true;
while (hasMorePages)
{
ct.ThrowIfCancellationRequested();
SearchPage response = await _httpClient
.GetFromJsonAsync<SearchPage>($"/search?q={query}&page={page}", ct);
foreach (SearchResult result in response.Items)
yield return result;
hasMorePages = response.HasNextPage;
page++;
}
}The caller can start displaying page 1's results the instant they arrive, while page 2 hasn't even been requested yet — and if the caller stops early (say, the user closed the search panel), later pages are simply never fetched, exactly like the "stop early" benefit from the previous lesson. The [EnumeratorCancellation] attribute is what lets a CancellationToken parameter on an async-iterator method actually be wired up to the token that .WithCancellation(ct) supplies on the consuming side — without it, a token passed as a normal parameter here wouldn't correctly connect to the enumerator that await foreach creates.
A log file with millions of lines, processed without ever holding more than one line in memory at a time:
public async IAsyncEnumerable<string> ReadLinesAsync(string path)
{
using StreamReader reader = File.OpenText(path);
while (!reader.EndOfStream)
{
string? line = await reader.ReadLineAsync();
if (line is not null)
yield return line;
}
}
// Consuming it — find the first ERROR line without reading the whole file:
await foreach (string line in ReadLinesAsync("app.log"))
{
if (line.Contains("ERROR"))
{
Console.WriteLine($"Found: {line}");
break; // the rest of a multi-gigabyte file is never read
}
}Every await reader.ReadLineAsync() frees the thread while the next chunk is read off disk. Memory use stays flat regardless of whether the file is a hundred lines or a hundred million — only the current line needs to exist in memory at once, the same benefit you saw with the order-export example in the previous lesson, now applied to a file instead of a database.
A synchronous iterator is like a cook with every ingredient already prepped and sitting on the counter — handing you one plate the moment you ask, with no waiting. async IAsyncEnumerable<T> is a short-order cook: when you ask for the next dish, they might need to step away to grill something first (the await), and only once it's ready do they hand it to you (the yield return). Crucially, they don't grill dish #5 until you've actually asked for it — no wasted food sitting around for orders that were never placed.
As the previous lesson described at a high level, the compiler generates a single state machine for a method like this, capable of two distinct kinds of suspension. Concretely, each call to MoveNextAsync() resumes the state machine from wherever it last paused — whether that pause was at an await (waiting on a Task) or at a yield return (waiting for the caller to ask for the next item) — and runs forward until it hits the next pause of either kind, or falls off the end of the method (which is what makes the final MoveNextAsync() return false).
This is exactly why the order of statements you write is exactly the order things happen: await the page, then yield its items, then loop back to await the next page — nothing runs ahead of where the caller has actually asked to be.
It's more precise to say: an async method that contains yield return can only declare its return type as IAsyncEnumerable<T> (or IAsyncEnumerator<T>) — you can't combine yield return with a return type of Task<T>. Ordinary async Task<T> methods that don't yield anything are unaffected and remain exactly what you already know from earlier in this module.
Just like a synchronous iterator method, calling SearchProductsStreamAsync(query) by itself doesn't execute a single line inside it — it hands back an IAsyncEnumerable<T> object ready to be enumerated. The body only actually starts running once something calls GetAsyncEnumerator() and then MoveNextAsync() on it — which is exactly what await foreach does for you.
Adding a plain CancellationToken parameter to an async-iterator method and assuming .WithCancellation(ct) on the caller's side will automatically feed it in.
Mark the parameter [EnumeratorCancellation], as shown in the search example — this is the detail that actually wires the caller's token through to your method's token.
Fetching every page up front into a List<SearchResult> before yielding anything — this throws away the entire point of streaming; the caller is back to waiting for everything before seeing the first item.
Yield each item (or batch) as soon as it's actually available, right after the await that produced it — don't collect first and yield later.
Opening a StreamReader (or a database connection) without a using, hoping it gets cleaned up eventually.
Use using exactly as shown in the file-reading example — the compiler's generated state machine correctly runs the disposal when the enumeration ends, whether that's because the file was fully read or because the caller broke out of the loop early.
MoveNextAsync() resumes exactly where the last suspension left off.[EnumeratorCancellation] to correctly wire up a CancellationToken parameter.
async IAsyncEnumerable<T> and yield return — the same iterator syntax you already know, now free to await real asynchronous work between yields.MoveNextAsync() call resumes the method exactly where it last paused — at an await or a yield return — and runs forward to the next pause.[EnumeratorCancellation] so it correctly connects to a caller's .WithCancellation(ct).You've now seen both sides of an async stream — consuming with await foreach, and producing with async IAsyncEnumerable<T>. Let's check the producing side is clear.
1. What three things must be true about a method for the compiler to treat it as an async iterator?
Correct: B
Why B is correct: All three conditions together — the async modifier, an IAsyncEnumerable<T>/IAsyncEnumerator<T> return type, and at least one yield return in the body — are what tell the compiler to generate the combined async-iterator state machine.
Why A is incorrect: Accessibility and staticness have nothing to do with this — plenty of instance methods are async iterators.
Why C is incorrect: That describes an ordinary async method returning a buffered list, not a streaming async iterator — no yield return involved.
Why D is incorrect: async plus await alone just describes a normal async Task method — yield return and the IAsyncEnumerable<T> return type are what make it a streaming producer specifically.
Reinforcement: All three markers — async, the right return type, and yield return — must appear together.
2. In SearchProductsStreamAsync from this lesson, when does the HTTP request for page 2 actually happen?
Correct: B
Why B is correct: The while loop only reaches the await for page 2 after every item from page 1 has been yielded and the caller has asked for the next one (another MoveNextAsync call) — production is entirely driven by the caller's demand.
Why A is incorrect: Calling the method doesn't execute any of its body yet — nothing runs until enumeration actually starts, and even then, page 2 specifically waits until page 1's items are exhausted.
Why C is incorrect: The pages are fetched sequentially, one await at a time, inside a single loop — there's no parallel fetching here.
Why D is incorrect: The while loop continues fetching subsequent pages as long as hasMorePages is true and the caller keeps asking for more items.
Reinforcement: On-demand production means later work only happens once the caller has actually asked for it.
3. Why does the file-reading example (ReadLinesAsync) keep memory usage roughly constant regardless of file size?
Correct: B
Why B is correct: Each call to ReadLineAsync reads just one line, which is immediately yielded to the caller rather than accumulated anywhere. Whether the file has a hundred lines or a hundred million, only one line's worth of data needs to be in memory at any given moment.
Why A is incorrect: There's no compression involved — the memory efficiency comes from not buffering, not from compressing data.
Why C is incorrect: .NET doesn't impose an automatic memory cap like this — the efficient behavior here is a direct result of how the method is written.
Why D is incorrect: Some representation of the line's text does exist in memory briefly (it has to, to be processed) — the point is that it's just one line at a time, not the whole file.
Reinforcement: Yielding each item right after producing it, instead of buffering into a collection, is exactly what keeps memory flat for large or unbounded sequences.
4. What is the purpose of the [EnumeratorCancellation] attribute on a CancellationToken parameter in an async-iterator method?
Correct: B
Why B is correct: Without this attribute, a plain CancellationToken parameter on an async-iterator method wouldn't correctly receive the token supplied by a caller's .WithCancellation(ct) call — [EnumeratorCancellation] is specifically what wires that connection up correctly.
Why A is incorrect: It has nothing to do with performance — it's about correctly routing which token gets observed.
Why C is incorrect: No timeout is added automatically — cancellation still has to be triggered explicitly by whoever owns the CancellationTokenSource.
Why D is incorrect: It's specific to CancellationToken parameters on async-iterator methods (those using yield return) — ordinary async methods don't need it.
Reinforcement: Async-iterator methods need this specific attribute for cancellation to actually reach the method's own token correctly.
You now understand both sides of async streams — consuming with await foreach, and producing with async IAsyncEnumerable<T> and yield return. Next up: the final lesson in this module — a curated tour of the most common async mistakes developers make, and how to avoid every one of them.
dotnetmadeeasy.com — Learn C# and .NET, the right way.