DEV Community

Nick
Nick

Posted on AI-assisted

Async LINQ: Why ToListAsync Exists (And Why WhereAsync Doesn't)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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

  1. Query building (Where, Select) — no async needed
  2. Query execution (ToList, First, Count) — use async versions
  3. Large results? AsAsyncEnumerable() for streaming
  4. Parallel queries? Separate DbContext per task
  5. 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)