IAsyncEnumerable in .NET: stream without hiding the cost
Use IAsyncEnumerable in .NET to process results as they arrive. Compare buffering, propagate cancellation, and keep resources alive until enumeration ends.
The export has finished reading every row before the first line reaches its destination. Memory
climbs while the database is busy, then the database goes quiet while the application finally
starts writing. Someone added async to the query, so the request no longer blocks a thread while
waiting. It still holds the entire result in a list.
Asynchronous waiting and streaming solve different problems. The first gives a thread back while
an operation waits. The second lets the consumer work before the complete result exists.
IAsyncEnumerable<T> describes a sequence whose next item can arrive asynchronously. It is a
useful contract for an export, a paged upstream API, or a file reader. It is not a promise that
every layer uses constant memory, or that the resources behind the sequence are cheap to keep open.
Imagine an input containing 100,000 accepted records. The destination writes one record at a time. If the program builds a list first, it retains references to all 100,000 records before writing record one. If it reads and writes sequentially, it need not retain earlier records after writing them. That is an object-lifetime argument, not a benchmark prediction: record size, buffers, and the destination still determine the measured memory use.
The two consumer shapes make the distinction visible:
// Buffering: the writer starts after the list is complete.
var records = new List<string>();
await foreach (var line in ReadAcceptedLinesAsync(path, cancellationToken))
{
records.Add(line);
}
foreach (var line in records)
{
await WriteRecordAsync(line, cancellationToken);
}
// Streaming: each write finishes before requesting the next item.
await foreach (var line in ReadAcceptedLinesAsync(path, cancellationToken))
{
await WriteRecordAsync(line, cancellationToken);
}In the second loop, a slow destination naturally delays the next request to the iterator. There
is no application queue growing between these two operations. A producer can still prefetch or
buffer internally, so inspect its implementation before claiming the entire pipeline is bounded.
The same caution applies if WriteRecordAsync merely enqueues work and returns immediately.
What stays alive while the destination waits?
Buffer first
Complete result retained during writing
Source can close before writing begins.
Consume sequentially
- Read record
- Write record
- Request next
Source stays open across each write.
Streaming reduces how much data you keep, but it can increase how long you keep a resource.
The trade becomes clear when the input is a database reader. If the destination pauses for a minute, the reader can remain open for that minute. Reducing the list allocation might improve one request while a connection pool fills with long-lived exports. Treat memory and resource occupancy as separate measurements.
Here is a complete file-reading iterator for modern .NET. Each nonempty input line represents one record in this deliberately simple format; it is not a CSV parser.
using System.Runtime.CompilerServices;
static async IAsyncEnumerable<string> ReadAcceptedLinesAsync(
string path,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
using var reader = new StreamReader(path);
while (await reader.ReadLineAsync(cancellationToken) is { } line)
{
cancellationToken.ThrowIfCancellationRequested();
if (line.Length == 0)
{
continue;
}
yield return line;
}
}The important ownership choice is where reader is created. It belongs to the iterator body,
which executes during enumeration. Its using scope spans the yields. A caller does not receive
a sequence backed by a reader that a helper has already disposed.
The compiler-generated asynchronous enumerator supports disposal. An await foreach loop
disposes it when the loop finishes, including when the consumer leaves with break or throws.
The cancellation attribute makes the token supplied through enumeration available to the iterator.
Microsoft's async stream tutorial
documents both parts of that contract.
The code passes cancellation to the actual read rather than merely checking it before a potentially long wait. StreamReader.ReadLineAsync provides the token-taking overload. The extra check also catches cancellation between a completed read and handing off the record. Cancellation remains cooperative; an iterator that ignores the token can continue doing work.
If you manually obtain an enumerator, you inherit responsibility for disposing it. Prefer the loop syntax until there is a concrete reason to manage that protocol yourself. A custom consumer that abandons an enumerator without disposal can keep the underlying file or connection alive.
Sometimes the caller receives a sequence without supplying the token at construction time. It can attach one when consuming it:
var records = ReadAcceptedLinesAsync(path);
await foreach (var record in records.WithCancellation(cancellationToken))
{
await WriteRecordAsync(record, cancellationToken);
}There are two independently relevant waits here: obtaining the next record and writing the current one. Cancelling enumeration alone does not cancel a destination call already in progress. Passing the same token through both gives the operation a coherent stopping boundary. The existing cancellation propagation guide follows that boundary through an HTTP request.
Avoid turning a cancelled export into a successful partial result unless that is the documented contract. Catching every exception and returning the rows seen so far makes a truncated file look complete. For a resumable job, persist an explicit checkpoint and completion state. For a file that must appear atomically, write to a temporary destination and publish it only after successful completion. Those are output semantics; changing the return type cannot decide them for you.
Deferred execution also moves failure timing. Opening the file can fail when enumeration starts, and a later read can fail after earlier writes succeeded. Put error handling around consumption, not just around the method call that obtains the sequence. If an API must reject invalid arguments immediately, use an ordinary validating wrapper that returns a separate iterator.
An asynchronous iterator around a buffered source does not recover the memory already spent:
static async IAsyncEnumerable<OrderRow> ExportAsync(ShopContext db)
{
var rows = await db.Orders
.Select(order => new OrderRow(order.Id, order.Total))
.ToListAsync();
foreach (var row in rows)
{
yield return row;
}
}The list remains live while the caller consumes it. Returning rows individually only changes the
outer interface. For an EF Core query, project the required columns and consume the query through
AsAsyncEnumerable while the context remains alive. Keep database filters before the switch to
client-side enumeration.
Even then, a retrying execution strategy can buffer results internally, and split queries can buffer earlier result sets. Tracking entity results also retains state beyond the current row. Microsoft's efficient querying guide describes those exceptions. Verify the query shape and provider behavior before promising a fixed memory footprint.
The same review applies outside databases. A JSON parser might construct a complete document. A sorting step might require the whole input. A logging sink might retain every record for a batch. Sketch the entire path from source to destination and label each place that can accumulate data. The interface at the center is only one box on that sketch.
The simple loop above processes one record at a time. That can be exactly right for an ordered
file export, but too slow for independent network operations. Starting a task for every record
changes the capacity model: those tasks and their inputs remain live until completion. Collecting
them all for Task.WhenAll recreates an unbounded backlog.
If overlap is necessary, choose an explicit concurrency limit and decide whether output order matters. A bounded channel can separate reading from processing, but then its capacity and shutdown behavior become part of the design. The channels guide explains that additional queue. It is useful when you need one, not a requirement for every stream.
For the export, measure time to first written record, peak retained memory, total completion time, and the source's open-resource duration. Run with a slow destination as well as a fast one. Test an empty input, cancellation during a read, failure during a write, and a consumer that stops after its first item. These cases reveal ownership mistakes a happy-path throughput run misses.
The useful habit is asking what stays alive across each await and yield return. Apply that
question to a small exercise before changing a production export: compare a buffered implementation
with a sequential consumer, then introduce a slow sink and explain the changed resource lifetime.
Katabench's performance documentation explains its measurement model, while the grading guide separates correctness checks from additional constraints. Use those distinctions when practicing: an answer can return the right records and still retain work it never needed to keep. Streaming becomes a good choice when you can show which retained work disappears and which resources now need a longer-lived owner.
Practice .NET Performance
- Open in the editor: Two Sum
Algorithm Easy Free, no account needed
Two Sum
Find the indices of the two numbers that add up to a target.
More like this: C# coding challenges →