Stream results with IAsyncEnumerable

ยท 2 min read

Loading 100,000 rows into a list before sending the first one wastes memory and time. IAsyncEnumerable<T> lets you process and return items as they arrive.

A lot of integration code follows the same shape: load everything into a List<T>, then loop over it. That's fine for 50 items. For an export of 200,000 orders, or a sync that pages through a third-party API, it means holding everything in memory and waiting for the last page before doing anything with the first.

IAsyncEnumerable<T> is an asynchronous sequence. You consume it with await foreach, and each item is handled as soon as it's available.

Paging through an API

Hide the paging inside an async iterator, and callers see a simple stream of items:

public async IAsyncEnumerable<Contact> GetAllContactsAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    string? cursor = null;
    do
    {
        var page = await http.GetFromJsonAsync<ContactPage>(
            $"contacts?limit=200&cursor={cursor}", ct);

        foreach (var contact in page!.Items)
            yield return contact;

        cursor = page.NextCursor;
    }
    while (cursor is not null);
}
await foreach (var contact in crm.GetAllContactsAsync(ct))
{
    await UpsertAsync(contact, ct);
}

Only one page is in memory at a time, and work starts as soon as the first page arrives. If the caller stops early, with break or by taking the first few items, the remaining pages are never requested.

The [EnumeratorCancellation] attribute matters. It connects the token parameter to the one passed in by WithCancellation(), so cancellation works however the sequence is consumed.

Streaming from EF Core

await foreach (var order in db.Orders
    .Where(o => o.CreatedAt >= since)
    .AsNoTracking()
    .AsAsyncEnumerable()
    .WithCancellation(ct))
{
    await writer.WriteRecordAsync(order, ct);
}

Rows are read from the database as you go, instead of all at once. AsNoTracking() keeps EF Core from holding every entity in its change tracker, which would defeat the purpose.

Streaming from an API endpoint

ASP.NET Core understands IAsyncEnumerable<T>. Return one from a minimal API or controller and the JSON array is written to the response as items are produced, rather than buffered first:

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

Things to watch

  • Keep the database connection in mind. A streamed EF Core query keeps its connection and reader open until you finish. Don't do slow work per item while holding it if you can batch instead.
  • Don't call ToListAsync() on it out of habit. That puts you right back where you started.
  • Errors arrive mid-stream. A failure on page 37 surfaces during the loop, after you've already processed 36 pages. Make sure the work you did is safe to resume or repeat.

Takeaway

When you're dealing with large or paged data, return IAsyncEnumerable<T> and consume it with await foreach. You use less memory, start working sooner, and stop early for free.