Three independent one-second waits, done one after another, cost three seconds. Done together, they cost about one. Task.WhenAll is how you get the "together."
Back in the sync-vs-async lesson, you saw that awaiting one operation frees the thread instead of blocking it — a real win on its own. But there's a second, bigger win still on the table: if you need to fetch a customer's profile, their recent orders, and their loyalty points — three completely independent calls to three completely independent services — why wait for the first one to finish before even starting the second?
If each call takes about a second, awaiting them one after another costs roughly three seconds total, even though none of them depends on any of the others. In this lesson, you'll learn how Task.WhenAll lets you start several independent asynchronous operations at once, wait for all of them together, collect every result, and understand exactly how it behaves when one or more of those operations fail.
Task.WhenAll takes a collection of Tasks that are already running and gives you back a single new Task that completes only once every one of them has completed. Instead of awaiting each one in turn — "wait for A, then wait for B, then wait for C" — you kick all three off, then await one combined thing: "wait until A, B, and C have all finished, however long the slowest one takes."
Task.WhenAll is a static method on Task with several overloads. The two you'll use constantly:
// No results to collect — just "wait until all of these are done"
public static Task WhenAll(IEnumerable<Task> tasks);
public static Task WhenAll(params Task[] tasks);
// Results to collect — returns an array of every Task's result, in order
public static Task<TResult[]> WhenAll<TResult>(IEnumerable<Task<TResult>> tasks);
public static Task<TResult[]> WhenAll<TResult>(params Task<TResult>[] tasks);The generic overload is the one you'll reach for most: hand it a collection of Task<TResult>, and once every one of them completes successfully, the Task it gives you back completes with a TResult[] — one entry per input task, in the same order you passed them in, regardless of which one actually finished first.
Task.WhenAll does not start your operations — it just observes Tasks that are already in flight. The starting happens the moment you call the async method (e.g., GetOrdersAsync()) without awaiting it yet. That's the detail almost every beginner mistake in this lesson traces back to.
Consider fetching data for a customer dashboard from three unrelated services:
Customer profile = await _customerService.GetProfileAsync(id); // ~1s
List<Order> orders = await _orderService.GetRecentOrdersAsync(id); // ~1s
LoyaltyStatus loyalty = await _loyaltyService.GetStatusAsync(id); // ~1s
// Total: roughly 3 secondsEach await here properly frees the thread while it waits — no blocking, exactly as the previous lessons taught. But the three operations don't depend on each other at all: the loyalty lookup doesn't need the customer's profile first. Awaiting them one at a time still adds their durations together, because each call doesn't even begin until the previous await has fully completed.
What's needed is a way to start all three operations immediately, let them run concurrently while each one waits on its own network call, and only pause once every one of them has actually finished — so the total time is roughly the duration of the slowest one, not the sum of all three.
The same logic, restructured to start every call before awaiting any of them:
Task<Customer> profileTask = _customerService.GetProfileAsync(id); // started, not awaited
Task<List<Order>> ordersTask = _orderService.GetRecentOrdersAsync(id); // started, not awaited
Task<LoyaltyStatus> loyaltyTask = _loyaltyService.GetStatusAsync(id); // started, not awaited
await Task.WhenAll(profileTask, ordersTask, loyaltyTask);
Customer profile = profileTask.Result; // already completed — safe to read
List<Order> orders = ordersTask.Result; // already completed — safe to read
LoyaltyStatus loyalty = loyaltyTask.Result; // already completed — safe to read
// Total: roughly 1 second — the duration of the slowest callAll three calls begin the instant each method is invoked. By the time Task.WhenAll is reached, all three are already running concurrently. The single await then suspends until the slowest one finishes — not the sum of all three.
Sequential (await each one, one after another):
0s ─────[ A: 1s ]─────[ B: 1s ]─────[ C: 1s ]─────▶ 3s total
Concurrent (Task.WhenAll):
0s ─────[ A: 1s ]────────────────────────────────▶
0s ─────[ B: 1s ]────────────────────────────────▶
0s ─────[ C: 1s ]────────────────────────────────▶
▲ all three overlap — total ≈ slowest one ≈ 1s
Task<string> taskA = FetchAsync(urlA); // starts running immediately
Task<string> taskB = FetchAsync(urlB); // starts running immediately, concurrently with A
Task<string>[] tasks = { taskA, taskB };
// or: var tasks = urls.Select(u => FetchAsync(u)).ToArray();
TResult[] back as the await's result..Result is now safe to read — it's already completed, so reading it doesn't block.Fetching several URLs concurrently and collecting their content lengths — using the array-returning generic overload directly:
public async Task<int[]> GetContentLengthsAsync(string[] urls)
{
// Start every request without awaiting — all begin running now
Task<int>[] tasks = urls
.Select(url => GetLengthAsync(url))
.ToArray();
// Suspend here until every one of them has completed
int[] lengths = await Task.WhenAll(tasks);
return lengths; // one entry per URL, in the same order as the input
}
private async Task<int> GetLengthAsync(string url)
{
string content = await httpClient.GetStringAsync(url);
return content.Length;
}Walking through it:
.Select(url => GetLengthAsync(url)) calls the async method for every URL — each call starts running immediately, before .ToArray() even finishes materializing the array.await Task.WhenAll(tasks), every request is already in flight concurrently.int[] preserves input order — lengths[0] corresponds to urls[0], even if a later URL's request happened to finish first.An e-commerce product page that needs pricing, inventory, and reviews from three separate downstream services before it can render:
public async Task<ProductPageViewModel> GetProductPageAsync(int productId, CancellationToken ct)
{
Task<PriceInfo> priceTask = _pricingService.GetPriceAsync(productId, ct);
Task<int> stockTask = _inventoryService.GetStockCountAsync(productId, ct);
Task<List<Review>> reviewsTask = _reviewService.GetTopReviewsAsync(productId, ct);
await Task.WhenAll(priceTask, stockTask, reviewsTask);
return new ProductPageViewModel(
Price: priceTask.Result,
InStock: stockTask.Result > 0,
Reviews: reviewsTask.Result
);
}Three completely independent downstream calls, none of which depend on the others' results, all start the moment their methods are invoked and run concurrently. The page only waits as long as the slowest of the three — instead of paying for all three durations back to back. Notice the same CancellationToken is passed into every one of them, exactly as the previous lesson recommended: cancelling the overall request cancels all three concurrent calls at once.
Suppose both priceTask and reviewsTask fail. Task.WhenAll still waits for every task to reach a completed state — including the failed ones and any that are still succeeding — before its own returned Task completes. Internally, it records all the exceptions from every faulted task, not just the first one to fail.
However, when you await the WhenAll Task directly, only one of those exceptions is actually re-thrown into your code (typically the first one in the list) — await always unwraps and throws just a single exception, even when more than one occurred. To see every failure, you need to look at the individual tasks (or the combined task's .Exception property) explicitly:
Task<PriceInfo> priceTask = _pricingService.GetPriceAsync(productId, ct);
Task<int> stockTask = _inventoryService.GetStockCountAsync(productId, ct);
Task<List<Review>> reviewsTask = _reviewService.GetTopReviewsAsync(productId, ct);
Task allTasks = Task.WhenAll(priceTask, stockTask, reviewsTask);
try
{
await allTasks;
}
catch
{
// Only ONE exception was rethrown here by await — but ALL of them were captured.
// allTasks.Exception is an AggregateException containing every failure:
foreach (Exception ex in allTasks.Exception!.InnerExceptions)
{
_logger.LogError(ex, "A downstream call failed while loading the product page.");
}
}This distinction matters in practice: if you only log ex.Message from the single exception await gave you, you might think only pricing failed — while reviews silently failed too, and you'd never know unless you inspected every underlying task.
Imagine you need replies from three different people before you can proceed. You could mail a letter to the first person, wait at the mailbox until their reply arrives, then mail the second letter and wait again, then the third. That's sequential awaiting — needlessly slow, since none of them needed to hear from the others first.
Or you could mail all three letters the same afternoon, then simply wait until every reply has arrived before moving on. You're still waiting the whole time (you haven't started the next task without their input) — but the total wait is however long the slowest reply takes, not the sum of all three. Task.WhenAll is mailing all three letters up front, then waiting for the last envelope to arrive.
Task.WhenAll does not spin up new threads and does not itself do any work. It creates and returns a new Task (a "combinator" task) that registers a lightweight continuation on every task you passed in. As each underlying task completes — whether by finishing normally, faulting, or being canceled — that continuation checks: have all of them finished now? Once the last one has, the combined Task itself transitions to its completed state, and any code awaiting it resumes — on a thread-pool thread, exactly as with any other await.
This is precisely why concurrency here doesn't require any manual thread management on your part: each individual awaited operation (an HTTP call, a database query) was already using the OS's asynchronous I/O facilities with no thread dedicated to its wait, exactly as covered in the Task and Task<T> lesson. Task.WhenAll simply lets several of those non-blocking waits happen during the same span of time instead of one after another — it doesn't change how any individual operation itself runs.
This is the single most common mistake with this lesson's material:
// This is SEQUENTIAL, not concurrent — despite using async/await!
foreach (var url in urls)
{
string content = await GetStringAsync(url); // starts AND fully awaits before the next iteration
}Each loop iteration doesn't even call GetStringAsync for the next URL until the current await has fully completed. This is exactly as sequential as the "before" example earlier in this lesson — async/await alone doesn't create concurrency; only starting multiple operations before awaiting any of them does.
For I/O-bound work — the typical case for Task.WhenAll — the speedup doesn't come from using more CPU cores at once. It comes from the fact that no thread was ever dedicated to any of the individual waits in the first place, so many of them can be "in flight" simultaneously without needing many threads. This is a different kind of concurrency than CPU-bound parallel computation — the deeper mechanics of thread-pool scheduling belong to a more advanced lesson, but the practical result here holds regardless: independent I/O-bound waits genuinely overlap.
Wrong (sequential, defeats the entire purpose):
var a = await GetAAsync();
var b = await GetBAsync();
var c = await GetCAsync();Correct — start all three first, then await together:
Task<A> taskA = GetAAsync();
Task<B> taskB = GetBAsync();
Task<C> taskC = GetCAsync();
await Task.WhenAll(taskA, taskB, taskC);
var a = taskA.Result;
var b = taskB.Result;
var c = taskC.Result; Trusting that the single exception caught from await Task.WhenAll(tasks) is the only thing that went wrong.
When more than one task might fail and you need to know about all of them, inspect each task's status (or the combined task's .Exception.InnerExceptions) explicitly, as shown in the real-world example above.
Firing off a database write and a follow-up read that depends on that write's result, both "concurrently" through Task.WhenAll — a race condition, since the read might run before the write finishes.
Task.WhenAll is only correct for genuinely independent operations. If B needs A's result, that's still a normal sequential await A then await B.
await only rethrows one — inspect every task explicitly if you need to see them all.
Task.WhenAll waits for a whole collection of already-running Tasks to complete, instead of awaiting each one sequentially.async/await alone.TResult[] in the same order the input tasks were passed in.await only rethrows one — check every task explicitly to see every failure.You've seen how Task.WhenAll turns three separate one-second waits into roughly one second total. Let's check that the "start first, await together" mechanics are clear.
1. Why does this code run sequentially instead of concurrently, even though it uses async/await?
foreach (var id in ids)
{
var order = await GetOrderAsync(id);
results.Add(order);
}Correct: B
Why B is correct: Awaiting inside the loop body means each call to GetOrderAsync starts, runs to completion, and only then does the loop proceed to call it again for the next id. Nothing overlaps — this is exactly as sequential as awaiting each call by hand.
Why A is incorrect: foreach works perfectly fine with await inside its body — the code compiles and runs correctly, just sequentially.
Why C is incorrect: There's no such attribute — concurrency comes from how you structure the calling code (starting before awaiting), not from marking a method.
Why D is incorrect: await doesn't inherently create a new thread, and even if it did, awaiting inside the loop still forces each call to finish before the next one begins.
Reinforcement: Concurrency requires starting multiple operations before awaiting any of them — not just using async/await syntax.
2. What does Task.WhenAll(taskA, taskB, taskC) actually do?
Correct: B
Why B is correct: Task.WhenAll observes tasks that are already running — it registers a lightweight continuation on each and gives you back a combined Task that transitions to completed only once every input task has finished.
Why A is incorrect: The tasks must already be started before being passed to WhenAll — WhenAll itself never starts anything.
Why C is incorrect: WhenAll never cancels anything — it purely observes completion.
Why D is incorrect: That describes sequential execution, the opposite of what WhenAll achieves.
Reinforcement: WhenAll is an observer of already-in-flight work, not a launcher of new work.
3. Two of three tasks passed to Task.WhenAll fail with different exceptions. What happens when you await the resulting combined Task inside a try/catch?
Correct: A
Why A is correct: Task.WhenAll captures every exception from every faulted task internally, but await only ever rethrows one exception at a time into your code — even when multiple tasks failed. The rest remain accessible via the combined task's Exception property if you need them.
Why B is incorrect: The exceptions are not swallowed — one is actively rethrown by await, and all are retained internally.
Why C is incorrect: A faulted Task doesn't crash the program by itself — it's an ordinary exception that propagates through the normal try/catch mechanism at the await point.
Why D is incorrect: C# doesn't support throwing multiple exceptions from a single throw — await surfaces exactly one.
Reinforcement: If you need to know about every failure, not just the first one await gives you, inspect the underlying tasks' exceptions explicitly.
4. You use Task.WhenAll's generic overload with three tasks that finish in this order: taskC first, then taskA, then taskB. What order does the returned result array preserve?
Correct: B
Why B is correct: The result array's positions correspond to the order the tasks were passed into Task.WhenAll, not the order they actually finished in. This makes it safe to correlate results[i] with the original input at position i.
Why A is incorrect: Completion order can vary from run to run, which is exactly why WhenAll deliberately preserves input order instead — imagine trying to correlate results reliably if it didn't.
Why C is incorrect: Variable names have no runtime meaning at all — this isn't how the ordering works.
Why D is incorrect: The ordering is a documented, reliable guarantee — input order — not left to chance.
Reinforcement: WhenAll's result array is index-aligned with the tasks you passed in, which is what makes it safe and predictable to use.
5. A method fetches pricing, inventory, and reviews for a product from three unrelated services. Reviews aren't strictly required to render the page, but pricing and inventory both must succeed. Which statement about applying Task.WhenAll here is most accurate?
Correct: B
Why B is correct: The three calls are still independent and benefit from running concurrently. Because the caller cares differently about which specific task failed (reviews is non-critical, pricing/inventory are critical), it should check each task's own status/result individually after WhenAll completes, rather than relying on the single exception a plain await would surface.
Why A is incorrect: WhenAll works fine even when some tasks might fail — you just need to handle failures more carefully than a bare await.
Why C is incorrect: WhenAll accepts any number of tasks — two, three, or many more.
Why D is incorrect: Making a call synchronous would reintroduce blocking for no benefit — asynchronous concurrency and "this result is optional" are unrelated concerns.
Reinforcement: Concurrency and fine-grained failure handling aren't mutually exclusive — you can run things together with WhenAll and still inspect individual outcomes afterward.
You now know how to run independent async operations concurrently and collect every result. Next up: what if you don't need every result — just the first one to finish? That's Task.WhenAny.
dotnetmadeeasy.com — Learn C# and .NET, the right way.