Async LINQ and streaming with IAsyncEnumerable
Await foreach, the operators EF Core provides, and why System.Linq.Async is a tool with a real cost that is not always worth paying.
IAsyncEnumerable and await foreach
// EF Core T-SQL style: the query streams, rows arrive in batches
public async IAsyncEnumerable<Order> OpenOrdersAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var order in db.Orders
.Where(o => o.Status == OrderStatus.Open)
.OrderBy(o => o.Id)
.AsAsyncEnumerable()
.WithCancellation(ct))
{
yield return order;
}
}
// The operators EF Core supports on IAsyncEnumerable
var list = await db.Orders.Where(o => o.Total > 100).ToListAsync(ct);
var one = await db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);
var any = await db.Orders.AnyAsync(o => o.Status == OrderStatus.Open, ct);
var sum = await db.Orders.SumAsync(o => o.Total, ct);
await db.Orders.ForEachAsync(o => Console.WriteLine(o.Id), ct);
// AsAsyncEnumerable to mix streaming with a long-running consumer
await foreach (var order in db.Orders.AsAsyncEnumerable().WithCancellation(ct))
{
await ProcessAsync(order, ct);
}
// Batch a stream into pages without loading everything
public static async IAsyncEnumerable<IReadOnlyList<T>> ChunkAsync<T>(
IAsyncEnumerable<T> source, int size,
[EnumeratorCancellation] CancellationToken ct = default)
{
var buffer = new List<T>(size);
await foreach (var item in source.WithCancellation(ct))
{
buffer.Add(item);
if (buffer.Count == size)
{
yield return buffer;
buffer = new List<T>(size);
}
}
if (buffer.Count > 0) yield return buffer;
}- Add
[EnumeratorCancellation]to the token parameter of an async iterator, or the token a caller passes throughWithCancellationis ignored. - EF Core buffers internally in batches; a streaming query still holds a connection open for its duration, so keep the consumer fast.
- An
await foreachcannot cross a transaction boundary casually: a long stream inside an open transaction holds locks for its whole duration. - Returning
IAsyncEnumerableto a client is what ASP.NET Core uses to stream a response, but the enumerator must be disposed — always useawait foreach.
System.Linq.Async and its limits
// The System.Linq.Async package adds Where, Select and friends over IAsyncEnumerable
using System.Linq;
IAsyncEnumerable<int> doubled = source.SelectAwait(async x => x * 2);
// Inside a database query this is a mistake: the operator cannot be translated,
// so every row is fetched first and the work happens in memory.
var wrong = db.Orders
.ToAsyncEnumerable()
.Where(o => o.Total > 100) // client side!
.ToListAsync();
// The right order: filter and project in the query, then switch to async streaming
var right = db.Orders
.Where(o => o.Total > 100)
.Select(o => new { o.Id, o.Total })
.AsAsyncEnumerable();
await foreach (var o in right.WithCancellation(ct))
Console.WriteLine(o.Id);| Task | Use | Avoid |
|---|---|---|
| Fetch a list | ToListAsync | Materialising a huge result set |
| Fetch one | FirstOrDefaultAsync | Using FirstAsync when absence is normal |
| Existence | AnyAsync | CountAsync() > 0 |
| Aggregate | SumAsync, MaxAsync | Client-side aggregation after fetching |
| Stream | AsAsyncEnumerable + await foreach | Streaming inside a long transaction |
| Compose | System.Linq.Async operators | Composing over a queryable source |
| Process in bulk | ExecuteUpdateAsync | A loop that loads then saves each row |
⚠️
Async does not make a query faster; it stops a thread being blocked while it waits. If the bottleneck is a missing index or a query that returns too much, adding await everywhere changes nothing measurable.
FAQ
Is ToListAsync faster than ToList?
No, it is the same work without blocking a thread. On a busy server that matters for scalability; on a single-threaded script it does not.
When should I avoid streaming from EF Core?
When the consumer is slow, when the result is small, or when a transaction is open. In those cases a ToListAsync and a short connection is better for the database.
Related
Deferred execution and querying a database Query performance and avoiding N+1
Last refreshed 2026-09-18.