📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are async streams and IAsyncEnumerable of T? How do you produce, consume and cancel them?

Async iterators with yield return and await, await foreach, EnumeratorCancellation, ConfigureAwait, LINQ over async streams in .NET 10, EF Core and ASP.NET Core streaming.

Senior Coding Advanced async-streams await-foreach iasyncenumerable streaming

🎯 Key points

  • IAsyncEnumerable<T> is a sequence with asynchronous MoveNextAsync
  • Produce with an async iterator using await and yield return
  • Consume with await foreach; break disposes the enumerator
  • Pass cancellation with WithCancellation and [EnumeratorCancellation]
  • Streams reduce memory and time to first item and give natural backpressure
  • EF Core AsAsyncEnumerable and ASP.NET Core streaming responses use it
  • .NET 10 includes LINQ for IAsyncEnumerable<T> in the base library

⏱ 30-second answer

IAsyncEnumerable<T> is a sequence whose items arrive asynchronously. You produce one with an async iterator method that uses both await and yield return, and consume it with await foreach, which awaits MoveNextAsync() for each item and disposes the enumerator asynchronously. Cancellation is passed with .WithCancellation(ct) and received in the iterator through a parameter marked [EnumeratorCancellation]. It lets you process large or slow data item by item instead of buffering everything, for example EF Core AsAsyncEnumerable() or streaming JSON responses from ASP.NET Core. .NET 10 includes LINQ operators for it in the base library (System.Linq.AsyncEnumerable).

🗣 Full answer (senior)

How I would answer this in an interview

"IAsyncEnumerable<T> is a sequence whose items arrive asynchronously: the async counterpart of IEnumerable<T>. Its enumerator's MoveNextAsync() returns ValueTask<bool>, and it's IAsyncDisposable. I produce one with an async iterator, a method that uses both await and yield return, and consume it with await foreach, which awaits each item and disposes the enumerator asynchronously, including when I break early. Cancellation flows through .WithCancellation(ct) on the consumer side into an iterator parameter marked [EnumeratorCancellation]. Without that attribute the token is silently ignored. The point of async streams is to process data as it arrives: flat memory instead of buffering millions of rows, a faster first result, and natural backpressure, because the producer only advances when the consumer asks for the next item. You meet them in EF Core's AsAsyncEnumerable(), ASP.NET Core streaming responses, gRPC streaming, Channel<T>.Reader.ReadAllAsync() and Azure SDK paging. And since .NET 10, LINQ over async streams is built into the base library."


1. The interfaces

Type Members
IAsyncEnumerable<T> GetAsyncEnumerator(CancellationToken = default)
IAsyncEnumerator<T> ValueTask<bool> MoveNextAsync(), T Current, ValueTask DisposeAsync()

2. Producing: async iterators

public async IAsyncEnumerable<Order> ReadOrdersAsync([EnumeratorCancellation] CancellationToken ct = default)
{
    string? continuation = null;
    do
    {
        var page = await _api.GetPageAsync(continuation, ct);
        foreach (var order in page.Items) yield return order;
        continuation = page.Next;
    }
    while (continuation is not null);
}
Rule Detail
Lazy Nothing runs until enumeration starts
Re-run per enumeration Each await foreach executes the iterator again
try/finally and await using inside Run when the loop ends, breaks, throws or is cancelled
yield not allowed in catch/finally Same as sync iterators

3. Consuming

await foreach (var order in client.ReadOrdersAsync().WithCancellation(ct).ConfigureAwait(false))
{
    await ProcessAsync(order, ct);
    if (order.IsLast) break;      // DisposeAsync runs -> iterator's finally runs
}

4. Why it matters

Approach Memory Time to first item Backpressure
Task<List<T>> (buffer everything) Grows with data size After the last item None
IAsyncEnumerable<T> One item (or page) at a time After the first item Producer waits for the consumer
Channel<T> Bounded buffer First item Configurable (bounded channels)

5. Where you meet it

Technology Usage
EF Core query.AsNoTracking().AsAsyncEnumerable() streams rows from the reader
ASP.NET Core Returning IAsyncEnumerable<T> streams a JSON array via System.Text.Json without buffering
gRPC Server/client streaming calls
SignalR Streaming hub methods
Channels reader.ReadAllAsync(ct)
Azure SDK AsyncPageable<T>
LINQ .NET 10: System.Linq.AsyncEnumerable (Where, Select, ToListAsync, ...) in the box; earlier: the System.Linq.Async NuGet package

6. Choosing

Need Choose
Pull items one at a time from an async source IAsyncEnumerable<T>
Many producers/consumers, buffering, decoupled speeds Channel<T>
You need the whole result anyway (sorting, random access) Task<List<T>>
CPU-heavy multi-stage processing TPL Dataflow / Parallel.ForEachAsync

Production example

Our [reporting service] exported up to [2 million] rows to CSV by loading them into a List<T> with ToListAsync(), building the CSV, then writing it. Large exports spiked memory to [about 3 GB], pods were OOM-killed, and the gateway timed out before the first byte. I changed the query to AsNoTracking().AsAsyncEnumerable() and wrote each row to the response body through a StreamWriter, flushing every [1,000] rows and honouring the request's cancellation token, so abandoned downloads stopped the query. Memory stayed under [100 MB] regardless of export size, downloads started immediately, and we added an export load test with a memory limit to CI.

🔍 What the interviewer is checking

Checks modern async data handling.

Green flags

  • Writes an async iterator and an await foreach loop.
  • Handles cancellation with [EnumeratorCancellation].
  • Explains memory and latency benefits and disposal on break.
  • Knows real uses in EF Core and ASP.NET Core.

Red flags

  • Returns Task<IEnumerable<T>> and calls it streaming.
  • Ignores cancellation.

📚 Learn in depth

1. What the compiler generates

An async iterator compiles to a class that implements IAsyncEnumerable<T>, IAsyncEnumerator<T>, IAsyncDisposable and IValueTaskSource<bool>:

MoveNextAsync():
    resume state machine until:
        yield return x  -> Current = x; complete the ValueTask<bool> with true
        await (incomplete) -> return pending ValueTask<bool> (backed by ManualResetValueTaskSourceCore)
        end of method   -> complete with false
DisposeAsync():
    run pending finally blocks / await using disposals

Because MoveNextAsync returns a reusable ValueTask<bool>, streaming items doesn't allocate a task per item.

2. Cancellation plumbing

// Consumer
await foreach (var x in source.WithCancellation(ct)) { ... }

// What happens
source.GetAsyncEnumerator(ct)  ->  compiler routes ct into the [EnumeratorCancellation] parameter
Iterator parameter Consumer passes Effective token
[EnumeratorCancellation] CancellationToken ct ReadAsync(a) a
same ReadAsync().WithCancellation(b) b
same ReadAsync(a).WithCancellation(b) Linked a + b
No attribute .WithCancellation(b) Ignored (warning CS8425)

3. ASP.NET Core streaming

app.MapGet("/orders", (OrdersDb db, CancellationToken ct) =>
    db.Orders.AsNoTracking().OrderBy(o => o.Id).AsAsyncEnumerable());

System.Text.Json writes the array incrementally, flushing as it goes. Things to know:

  • Response headers are sent before all data is ready, so a mid-stream exception can't become a clean 500; the client sees a truncated response.
  • Keep per-item work light: the DB reader (and connection) stays open for the whole stream.
  • Use AsNoTracking() to avoid the change tracker growing with each row.

4. Writing CSV manually

app.MapGet("/export", async (HttpContext http, OrdersDb db, CancellationToken ct) =>
{
    http.Response.ContentType = "text/csv";
    await using var writer = new StreamWriter(http.Response.Body);
    int n = 0;
    await foreach (var o in db.Orders.AsNoTracking().AsAsyncEnumerable().WithCancellation(ct))
    {
        await writer.WriteLineAsync($"{o.Id},{o.Total}");
        if (++n % 1000 == 0) await writer.FlushAsync(ct);
    }
});

5. Pitfalls

Pitfall Effect
Enumerating twice Runs the query/API calls twice
Two concurrent enumerations on one DbContext InvalidOperationException (DbContext isn't thread-safe)
Exceptions Surface from MoveNextAsync mid-loop: handle around the await foreach
Slow consumer Holds source resources (connections, cursors) open
ToListAsync() on huge streams Recreates the buffering problem

6. Version notes

Version Change
C# 8 / .NET Core 3.0 IAsyncEnumerable<T>, async iterators, await foreach, [EnumeratorCancellation]
.NET 6 ASP.NET Core streams IAsyncEnumerable<T> JSON responses without buffering
EF Core 3.0+ AsAsyncEnumerable()
.NET 10 System.Linq.AsyncEnumerable LINQ operators in the base library

7. Try it yourself

  1. Run the code example: break after a high reading and observe the iterator's finally message.
  2. Remove [EnumeratorCancellation] and read warning CS8425; confirm the timeout no longer stops the feed.
  3. Return IAsyncEnumerable<T> from a minimal API and watch the response arrive progressively with curl -N.
  4. Compare memory for ToListAsync() vs AsAsyncEnumerable() over 1 million rows using dotnet-counters.
  5. Enumerate the same async iterator twice and count how many times the source runs.
  6. On .NET 10, use Where/Select/ToListAsync on an async stream without extra packages.

💻 Code

using System.Runtime.CompilerServices;

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));

await foreach (var reading in SensorFeed.ReadAsync("pump-7").WithCancellation(cts.Token))
{
    Console.WriteLine($"{reading.At:HH:mm:ss.fff} {reading.Value}");
    if (reading.Value > 95) break; // disposes the enumerator, runs the iterator's finally
}

// .NET 10: LINQ over IAsyncEnumerable<T> is built in (System.Linq.AsyncEnumerable).
var highs = await SensorFeed.ReadAsync("pump-8")
    .Where(r => r.Value > 50)
    .Take(3)
    .ToListAsync();
Console.WriteLine($"High readings: {highs.Count}");

public sealed record Reading(DateTime At, int Value);

public static class SensorFeed
{
    public static async IAsyncEnumerable<Reading> ReadAsync(
        string sensorId,
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        var random = new Random(sensorId.GetHashCode());
        try
        {
            while (true)
            {
                await Task.Delay(100, ct);          // simulate waiting for the next reading
                yield return new Reading(DateTime.UtcNow, random.Next(0, 100));
            }
        }
        finally
        {
            Console.WriteLine($"Feed {sensorId} closed"); // runs on break, cancellation or error
        }
    }
}

⚠ Common mistakes

1. Forgetting [EnumeratorCancellation]

What happens: the iterator has a CancellationToken parameter without the attribute. Why it is wrong: tokens passed with WithCancellation are silently ignored (CS8425). Do instead: mark the parameter; consumers use WithCancellation(ct).

2. Calling ToListAsync() on huge streams

What happens: "just materialise it, it's easier". Why it is wrong: brings back memory spikes and slow first results. Do instead: process in the loop or in bounded batches.

3. Enumerating twice expecting cached results

What happens: a stream is counted, then iterated. Why it is wrong: each enumeration reruns the iterator (and its queries). Do instead: materialise once if multiple passes are needed.

4. Slow per-item work while holding a DB reader open

What happens: each row triggers an HTTP call while streaming from SQL. Why it is wrong: the connection is held for the whole export; pool pressure. Do instead: keep per-item work light, or buffer in batches and release the reader.

5. Returning Task<IEnumerable<T>> and calling it streaming

What happens: the method buffers everything then returns. Why it is wrong: no streaming benefits. Do instead: return IAsyncEnumerable<T>.

6. Tracking entities while streaming

What happens: streaming EF queries without AsNoTracking(). Why it is wrong: the change tracker grows with every row. Do instead: AsNoTracking() for read-only streams.

7. Concurrent enumerations on one DbContext

What happens: two streams from the same context are enumerated in parallel. Why it is wrong: DbContext isn't thread-safe → exceptions. Do instead: one context per concurrent operation.

8. Expecting errors to become clean HTTP 500s mid-stream

What happens: an exception after the first item. Why it is wrong: headers already sent; the client sees a truncated body. Do instead: validate up front; design clients to detect incomplete streams.

9. Manually calling MoveNextAsync without disposing

What happens: custom enumeration code skips DisposeAsync. Why it is wrong: the iterator's finally/await using never run → leaked connections. Do instead: await foreach, or await using var e = source.GetAsyncEnumerator(ct).

10. Using async streams for fan-in from many producers

What happens: merging multiple producers through iterators. Why it is wrong: awkward and serialising. Do instead: a Channel<T> with multiple writers.

🏛 Senior insight: trade-offs

Async streams make streaming the default shape for large data. I use them at I/O boundaries (database, paging APIs, HTTP responses) and keep an eye on resource lifetime, because a stream holds its source open until the consumer finishes.

Scenario Pattern
Large DB export EF Core AsAsyncEnumerable() + streamed response
Paged REST API Async iterator over pages
Live feed Async iterator or Channel<T>.Reader.ReadAllAsync()
Need random access Materialise to a list

🔗 Follow-ups the interviewer may ask

  1. When: Candidate compares with yield return iterators Why: Checks synchronous iterators
    How to answer

    A method that uses yield return is an iterator, and the compiler rewrites it into a state machine class that implements IEnumerable<T> and IEnumerator<T>. Locals become fields, a state field remembers where it paused, and each MoveNext runs to the next yield return. yield break ends the sequence. Execution is lazy, and finally and using blocks run when iteration finishes or the consumer disposes early. Iterators can't have ref or out parameters or yield inside a catch, and async iterators add await. They're ideal for streaming and large or infinite sequences.

  2. When: Candidate mentions ReadAllAsync on a channel Why: Probes Channels
    How to answer

    System.Threading.Channels provides fast, async-friendly queues for passing data between producers and consumers. Channel.CreateBounded<T>(capacity) gives backpressure: when full, WriteAsync waits (or drops items, depending on BoundedChannelFullMode). Channel.CreateUnbounded<T>() never blocks writers but can grow without limit. Writers call Complete() when done; readers loop with await foreach (var x in reader.ReadAllAsync(ct)) and finish when the channel is completed and empty. Options such as SingleReader and SingleWriter enable faster internal implementations. Channels are the standard building block for in-process background queues and pipelines.

  3. When: Candidate mentions DisposeAsync on the enumerator Why: Checks IAsyncDisposable
    How to answer

    Implement IAsyncDisposable when cleanup involves asynchronous I/O, such as flushing a buffered stream, sending a final message or closing a network connection gracefully. DisposeAsync() returns ValueTask. await using var x = ...; compiles to try/finally with await x.DisposeAsync(), and you can write await using (x.ConfigureAwait(false)) in library code. A type that supports both should implement IDisposable and IAsyncDisposable, put async cleanup in protected virtual ValueTask DisposeAsyncCore(), and call GC.SuppressFinalize. In DI, use CreateAsyncScope(): disposing a provider or scope synchronously throws when it holds a service that implements only IAsyncDisposable.

🛠 Real-world problems: how you handle them

Streaming a large CSV export instead of buffering
Situation
A [reporting service] exported [2 million] rows by loading them into a `List<T>` before writing the CSV.
Signals
Memory spikes to [3 GB], container OOM kills and gateway timeouts on large exports.
Investigation
Memory profiling showed the whole result set and the CSV string in memory at once.
Resolution
Used EF Core `AsAsyncEnumerable()` with `AsNoTracking()` and wrote each row to the response body with a `StreamWriter`, honouring the request token.
Prevention
Added an export load test with memory limits in CI.

How to tell it (STAR)

Our [large exports] were crashing pods because everything was buffered. I changed the query to an async stream and wrote rows straight to the response, with cancellation from the request. Memory stayed under [100 MB] and downloads began immediately.

↩ Usually asked after

More in Async Advanced

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me