IAsyncEnumerable<T> brings the yield return pattern to async code. Instead of loading an entire collection into memory and returning it all at once, you can produce items one at a time, awaiting between each. This is invaluable for database queries, API pagination, file processing, and any scenario where data arrives incrementally.
The Interface
IAsyncEnumerable<T> is the async counterpart of IEnumerable<T>:
public interface IAsyncEnumerable<out T>
{
IAsyncEnumerator<T> GetAsyncEnumerator(
CancellationToken cancellationToken = default);
}
public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
T Current { get; }
ValueTask<bool> MoveNextAsync();
}
The key difference from IEnumerable<T> is that MoveNextAsync returns a ValueTask<bool>, allowing each iteration to asynchronously fetch the next item.
Producing Async Streams
Use async iterator methods with yield return:
public async IAsyncEnumerable<LogEntry> ReadLogsAsync(
string filePath,
[EnumeratorCancellation] CancellationToken ct = default)
{
using var reader = new StreamReader(filePath);
while (await reader.ReadLineAsync(ct) is { } line)
{
if (TryParseLogEntry(line, out var entry))
{
yield return entry;
}
}
}
The [EnumeratorCancellation] attribute is essential. It connects the CancellationToken passed to GetAsyncEnumerator (via WithCancellation) to your parameter.
Consuming Async Streams
Use await foreach — the async equivalent of foreach:
await foreach (var entry in ReadLogsAsync("app.log", cancellationToken))
{
if (entry.Level == LogLevel.Error)
{
await _alertService.NotifyAsync(entry);
}
}
Each iteration awaits the next item. If the producer is reading from a network stream that takes 50ms per item, the consumer naturally waits without blocking a thread.
Pagination with IAsyncEnumerable
A common real-world use is wrapping paginated APIs:
public async IAsyncEnumerable<User> GetAllUsersAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
string? cursor = null;
do
{
var page = await _httpClient.GetFromJsonAsync<UserPage>(
$"/api/users?cursor={cursor}&limit=100", ct);
foreach (var user in page!.Users)
{
yield return user;
}
cursor = page.NextCursor;
}
while (cursor != null);
}
The caller sees a simple stream of users. The pagination logic is encapsulated entirely within the producer. Memory usage stays low because only one page is in memory at a time.
LINQ with Async Streams
The System.Linq.Async NuGet package (maintained by the .NET team) provides LINQ operators for IAsyncEnumerable<T>:
// Install: dotnet add package System.Linq.Async
var recentErrors = ReadLogsAsync("app.log")
.Where(e => e.Level == LogLevel.Error)
.Where(e => e.Timestamp > DateTime.UtcNow.AddHours(-1))
.Take(50);
await foreach (var error in recentErrors)
{
Console.WriteLine(error.Message);
}
All the familiar operators — Where, Select, Take, Skip, OrderBy, GroupBy — work as expected, with async evaluation deferred until enumeration.
Entity Framework Core Integration
EF Core natively supports IAsyncEnumerable<T> through AsAsyncEnumerable():
public async IAsyncEnumerable<Product> GetExpensiveProductsAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var product in _context.Products
.Where(p => p.Price > 100)
.AsAsyncEnumerable()
.WithCancellation(ct))
{
yield return product;
}
}
This streams results from the database rather than buffering them into a List<T>. For large result sets, the memory savings can be substantial.
ASP.NET Core Controller Support
ASP.NET Core supports returning IAsyncEnumerable<T> from controller actions. The framework serialises items as they arrive:
[HttpGet("logs")]
public IAsyncEnumerable<LogEntry> GetLogs(CancellationToken ct)
{
return _logService.ReadLogsAsync(ct);
}
By default, ASP.NET Core buffers the entire sequence before sending the response. To enable true streaming, use System.Text.Json with response streaming or return the stream via a custom IActionResult.
Pitfalls
Avoid multiple enumeration. Unlike IEnumerable<T>, re-enumerating an IAsyncEnumerable<T> may re-execute the underlying I/O. If you need the data more than once, materialise it with ToListAsync().
Dispose the enumerator. await foreach handles this automatically via IAsyncDisposable. If you manually call GetAsyncEnumerator, wrap it in await using.
Cancellation requires WithCancellation. When consuming, pass your token explicitly:
await foreach (var item in stream.WithCancellation(ct))
{
// ct is forwarded to the enumerator
}
Without WithCancellation, the [EnumeratorCancellation] parameter in the producer receives CancellationToken.None.