Task.WhenAll and Task.WhenAny are the tools for running multiple async operations concurrently. Without them, awaiting one task after another executes them sequentially — the total time is the sum of all operations. With Task.WhenAll, all tasks start simultaneously and the total time is the maximum of all operations. For a controller action that fetches a post, its comments, and the author in parallel instead of sequentially, this can cut response time from 300ms to 100ms.
Task.WhenAll — Concurrent Execution
// ── Sequential — 300ms total (100 + 100 + 100) ───────────────────────────
var post = await _postRepo.GetByIdAsync(id); // 100ms
var comments = await _commentRepo.GetByPostIdAsync(id); // 100ms
var author = await _userRepo.GetByIdAsync(post.AuthorId); // 100ms
// ── Concurrent — ~100ms total (all run in parallel) ──────────────────────
var postTask = _postRepo.GetByIdAsync(id);
var commentsTask = _commentRepo.GetByPostIdAsync(id);
// Note: we cannot start authorTask yet — we need post.AuthorId first
await Task.WhenAll(postTask, commentsTask); // both complete before continuing
var post2 = postTask.Result; // .Result is safe here — already completed
var comments2 = commentsTask.Result;
var author2 = await _userRepo.GetByIdAsync(post2.AuthorId); // needs post2.AuthorId
// ── WhenAll with results array ─────────────────────────────────────────────
var emailTasks = new List<Task>
{
_email.SendAsync("alice@example.com", "Subject", "Body"),
_email.SendAsync("bob@example.com", "Subject", "Body"),
_email.SendAsync("carol@example.com", "Subject", "Body"),
};
await Task.WhenAll(emailTasks); // all three emails sent concurrently
// ── WhenAll with typed results ─────────────────────────────────────────────
Task<int> countTask = _db.Posts.CountAsync();
Task<int> activeTask = _db.Posts.CountAsync(p => p.IsPublished);
int[] results = await Task.WhenAll(countTask, activeTask);
int total = results[0];
int active = results[1];
await Task.WhenAll(task1, task2) returns, all tasks are guaranteed to have completed — so accessing task1.Result and task2.Result is safe without another await (the task is already done). However, if Task.WhenAll throws (because one or more tasks failed), the exception contains all failures via AggregateException. When caught in a normal try/catch, you see only the first exception — check exception.InnerExceptions for all failures if you need them.Task.WhenAll(emails.Select(e => SendAsync(e))) could overwhelm the email server, exhaust connection pools, or violate rate limits. Use SemaphoreSlim to limit concurrency: allow at most N tasks to run simultaneously. For batch operations, Parallel.ForEachAsync(items, new ParallelOptions { MaxDegreeOfParallelism = 10 }, async (item, ct) => await ProcessAsync(item, ct)) is the cleanest approach in .NET 6+.Task.WhenAll with EF Core requires care — a DbContext is NOT thread-safe. You cannot start two concurrent queries on the same DbContext instance. If you try, EF Core throws InvalidOperationException: A second operation was started on this context instance. For concurrent database operations, either use separate scoped DbContext instances or use single queries that fetch all needed data (like a single join query that gets the post and author together).Task.WhenAny — Race and Timeout Patterns
// ── Timeout pattern — complete the task or timeout after N seconds ────────
async Task<Post?> GetWithTimeoutAsync(int id, TimeSpan timeout)
{
var postTask = _repo.GetByIdAsync(id);
var timeoutTask = Task.Delay(timeout);
var first = await Task.WhenAny(postTask, timeoutTask);
if (first == timeoutTask)
{
// Timeout won — the query took too long
return null;
}
return await postTask; // query won — get the result (may throw)
}
var post = await GetWithTimeoutAsync(id, TimeSpan.FromSeconds(5));
// ── First-response-wins pattern ────────────────────────────────────────────
// Query two cache replicas; use whichever responds first
var cacheTask1 = _cache1.GetAsync<Post>($"post:{id}");
var cacheTask2 = _cache2.GetAsync<Post>($"post:{id}");
Task<Post?> first2 = await Task.WhenAny(cacheTask1, cacheTask2);
Post? cachedPost = await first2;
Common Mistakes
Mistake 1 — Starting tasks inside WhenAll (not starting them before)
❌ Wrong — tasks are not started yet; WhenAll awaits already-completed tasks:
// This is still sequential! Tasks are created (and completed!) one by one
await Task.WhenAll(
await _repo.GetAsync(1), // await inside the WhenAll call runs sequentially!
await _repo.GetAsync(2));
✅ Correct — start tasks first, then await them together:
var t1 = _repo.GetAsync(1); // started
var t2 = _repo.GetAsync(2); // started concurrently
await Task.WhenAll(t1, t2); // ✓ both running simultaneously
Mistake 2 — Concurrent EF Core queries on the same DbContext
❌ Wrong — InvalidOperationException: a second operation was started on this context.
✅ Correct — use separate DbContext instances per operation or combine into a single query.