Async LINQ: Why ToListAsync Exists (And Why WhereAsync Doesn't)
You write await dbContext.Products.ToListAsync(). Good. Then you wonder: where's WhereAsync? Why isn't await dbContext.Products.WhereAsync(p => p.Price > 100)?
The answer reveals something fundamental about how LINQ actually works.
Where the Async Boundary Lives
LINQ operators like Where, Select, OrderBy don't execute anything. They build a query. An expression tree. A recipe.
The execution happens at the terminal operator: ToList, First, Count, Any. That's when bytes flow over the network. That's when you want async.
// Building the query — instant, no I/O
var query = dbContext.Products
.Where(p => p.Price > 100) // No execution
.OrderBy(p => p.Name) // Still no execution
.Select(p => new { p.Name }); // Still building
// Actually executing — this is the I/O
var results = await query.ToListAsync(); // NOW it hits the database
There's no WhereAsync because Where doesn't touch the database. Making it async would be meaningless.
The Async Terminal Operators
EF Core provides async versions of all execution-triggering operators:
// Materialization
await query.ToListAsync();
await query.ToArrayAsync();
await query.ToDictionaryAsync(p => p.Id);
// Aggregation
await query.CountAsync();
await query.SumAsync(p => p.Price);
await query.AverageAsync(p => p.Price);
await query.MaxAsync(p => p.Price);
// Element access
await query.FirstAsync();
await query.FirstOrDefaultAsync();
await query.SingleAsync();
await query.SingleOrDefaultAsync();
// Existence
await query.AnyAsync();
await query.AnyAsync(p => p.Stock > 0);
await query.AllAsync(p => p.Price > 0);
// Finding
await query.FindAsync(id); // DbSet only — uses primary key
Each one triggers actual database communication. Each one should be awaited.
The Streaming Async Pattern
ToListAsync waits for all rows before returning. For large result sets, you might want to process as you receive:
await foreach (var product in dbContext.Products.AsAsyncEnumerable())
{
await ProcessProductAsync(product);
}
AsAsyncEnumerable() returns an IAsyncEnumerable<T> — each iteration asynchronously fetches the next item. You don't hold the entire result set in memory.
Fun fact: IAsyncEnumerable<T> was added in C# 8 (2019), but the concept of async iteration goes back to Rx (Reactive Extensions) from 2009. Rx's IObservable<T> pushed data to you; IAsyncEnumerable<T> lets you pull. Ten years from push to pull.
The ConfigureAwait Question
In library code, you often see:
var results = await dbContext.Products
.ToListAsync()
.ConfigureAwait(false);
ConfigureAwait(false) tells the await not to capture the synchronization context. In ASP.NET Core, this doesn't matter (there's no sync context). In desktop/UI apps or older ASP.NET, it prevents deadlocks and improves performance.
Rule of thumb:
-
Application code? Skip
ConfigureAwait -
Library code? Add
ConfigureAwait(false)everywhere
Async Pitfalls
Mixing Sync and Async
// Bad — blocks the thread
var products = dbContext.Products.ToList(); // Sync
await SomeOtherAsyncMethod();
// Good — stays async
var products = await dbContext.Products.ToListAsync();
await SomeOtherAsyncMethod();
One sync call in an async method can negate the benefits. Under load, thread pool starvation follows.
The False Async
// This is NOT async — the query executes in the Task.Run
var results = await Task.Run(() => dbContext.Products.ToList());
Task.Run offloads to a thread pool thread. You're still blocking a thread, just a different one. True async (ToListAsync) releases the thread during I/O.
Parallel Execution Trap
// Dangerous — DbContext is not thread-safe!
var task1 = dbContext.Products.CountAsync();
var task2 = dbContext.Orders.CountAsync();
await Task.WhenAll(task1, task2); // Concurrent access to same context!
DbContext isn't thread-safe. For parallel queries, use separate contexts:
await using var context1 = new AppDbContext();
await using var context2 = new AppDbContext();
var task1 = context1.Products.CountAsync();
var task2 = context2.Orders.CountAsync();
await Task.WhenAll(task1, task2); // Safe — separate contexts
Cancellation Tokens
All async operators accept cancellation:
public async Task<List<Product>> GetProductsAsync(CancellationToken ct)
{
return await dbContext.Products
.Where(p => p.IsActive)
.ToListAsync(ct); // Cancellable
}
When the token cancels, EF aborts the database command. Always wire cancellation through from your ASP.NET controller:
[HttpGet]
public async Task<IActionResult> GetProducts(CancellationToken ct)
{
var products = await _productService.GetProductsAsync(ct);
return Ok(products);
}
The Async LINQ to Objects Gap
Standard LINQ to Objects (in-memory) doesn't have async operators. If you're filtering an in-memory list, it's just:
var filtered = products.Where(p => p.Price > 100).ToList();
No await needed — no I/O involved. The async variants are for I/O-bound operations (database, file, network), not CPU-bound in-memory work.
The Rule
-
Query building (
Where,Select) — no async needed -
Query execution (
ToList,First,Count) — use async versions -
Large results?
AsAsyncEnumerable()for streaming - Parallel queries? Separate DbContext per task
- Always pass CancellationToken to async EF operations
Next time, we'll explore LINQ's aggregate operators — Sum, Average, Max, and the lesser-known Aggregate that lets you define custom aggregations. Hope to see you!
Top comments (1)