← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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.

The Technical Definition

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++; } }
Two kinds of pause, freely mixed

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.

Why Does It Exist?

The Problem — yield return Alone Can't Await

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.

The Need

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.

The Solution — async IAsyncEnumerable<T>

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.

Big Picture

Producing sideConsuming sideWhat it takes to write it
IEnumerable<T> + yield returnforeachNo await allowed inside
Task<T> (no yield)await onceOne value, computed with await inside
async IAsyncEnumerable<T> + yield returnawait foreachMany 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.

How It Works

A ROUND TRIP THROUGH SearchProductsStreamAsync
Step 1 — Caller starts `await foreach`, which calls MoveNextAsync()
Step 2 — It hits `await _httpClient.GetFromJsonAsync(...)`
Step 3 — The response arrives; the inner `foreach` reaches `yield return result`
Step 4 — Caller processes the item, then loops back for the next one
Step 5 — Repeat: yield remaining items from page 1, then await page 2, then yield those...

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.

Simple Example

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:

Real-World Example

Scenario 1 — Streaming paginated API results as they arrive

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.

Scenario 2 — Reading a large file line by line, asynchronously

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.

Analogy

A Short-Order Cook, Not a Meal-Prep Kitchen

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.

Under the Hood

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.

Common Confusion

1. "yield return in an async method means it must be returning IAsyncEnumerable" — not quite the rule

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.

2. Calling the method doesn't run any of its code yet

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.

Common Mistakes

Mistake 1 — Forgetting [EnumeratorCancellation] and expecting cancellation to "just work"

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.

Mistake 2 — Buffering everything into a list inside the method, then yielding from that

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.

Mistake 3 — Leaving a disposable resource unmanaged inside the iterator

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.

When Should I Use It?

You're the one fetching paginated data
Write the producer as async IAsyncEnumerable<T> so callers can consume it page-by-page.
You're reading a large file or stream
Yield each line or chunk right after awaiting it — no full-file buffering.
Not for a single asynchronous value
One result, computed with await? That's just async Task<T> — no yield needed.
Not for a small, already in-memory collection
If you already have a List<T> sitting in memory, just return it — there's nothing to stream.

Mental Model

yield return = "here's the next value, right now."
await = "I need to wait before I know what's next."
async IAsyncEnumerable<T> = a method free to do both, in any order, as many times as it needs.

Remember:
· Nothing runs until the caller starts enumerating — same as a synchronous iterator.
· Each MoveNextAsync() resumes exactly where the last suspension left off.
· Use [EnumeratorCancellation] to correctly wire up a CancellationToken parameter.

Key Takeaway


Check Your Understanding

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?

Show answer

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?

Show answer

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?

Show answer

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?

Show answer

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.