When you have several independent async operations to run, awaiting them one at a time turns parallelism into a serial queue. Task.WhenAll and Task.WhenAny are the two combinators that fix this: one waits for every task in a set to finish, the other returns as soon as the first one completes. This lesson covers when to use each, how exceptions and partial failure surface through them, and a handful of common patterns (parallel fetches, replica racing, timeouts) that show up often in real code.
await on a single task is straightforward: pause this method until the task finishes, then resume. The trap is what happens when you have more than one task to await. If you await them one after another, the second task doesn't start until the first one finishes, even when neither depends on the other.
Consider an e-commerce product page that needs three pieces of data from three different services: the product details, the live stock count, and recent customer reviews. None of the three needs the others to start. They could all run at the same time. Written naively, they don't.
Three calls, each taking 300 ms, take 900 ms total. The page loads three times slower than it has to. The fix has two parts: start every task before awaiting any of them, and then wait on the whole batch with one combinator.
Same data, one third the time. The three tasks ran concurrently because each was started before any await paused the method. Task.WhenAll then waited for all three to finish in parallel.
The timing difference is the whole point of combinators:
The diagram shows the same three operations on the same time axis. In the serial version, each task only starts when the previous one finishes, so the total time is the sum. In the parallel version, all three are started before any await, so the total time is roughly the slowest single task.
"Parallel" here means the async I/O overlaps, not that you use more threads. Each fetch spends its waiting time idle (no thread is blocked). Starting tasks before awaiting just lets those idle periods overlap. If your tasks are CPU-bound rather than I/O-bound, Task.Run plus WhenAll is the equivalent pattern.
Task.WhenAll takes a collection of tasks and returns a single task that completes when every task in the collection has completed. It comes in two main shapes.
Non-generic form: Task.WhenAll(IEnumerable<Task> tasks) returns Task. Use it when the tasks have no return value or you don't need the results back as a typed array.
Generic form: Task.WhenAll<TResult>(IEnumerable<Task<TResult>> tasks) returns Task<TResult[]>. Use it when every task returns the same result type and you want them gathered into an array, in the same order as the input.
Two details. The results array preserves the order of the input tasks, not the order in which they finished. So prices[0] is the price for products[0] no matter which fetch returned first. The call returns a Task<decimal[]>, and await on it unwraps to the decimal[] directly. You don't need to read each task's .Result afterward.
You can pass any sequence of tasks: an array, a list, or the result of a LINQ projection. The LINQ form is common in real code because it expresses "for each input, kick off a task" in one line.
The .ToArray() matters here. LINQ is lazy: without it, the Select would defer the call to FetchPriceAsync until something enumerated the sequence. Calling .ToArray() (or .ToList()) forces every task to start before WhenAll is invoked. Without the materialization, you'd be back to serial execution (or worse, the same task started multiple times if WhenAll enumerated the sequence twice).
Task.WhenAll returns immediately without blocking. The cost is one allocation for the aggregating task plus an internal counter that decrements as each input task completes. The work itself runs concurrently using whatever each task does internally (I/O completion, thread pool, etc.).
Async exceptions get more interesting once multiple tasks are in flight. With a single await, an exception thrown inside the awaited task is re-thrown at the await point and you handle it with try/catch. With Task.WhenAll, several tasks can fail at once, and the API has to do something sensible with all of them.
The behavior here is unintuitive on first encounter. Task.WhenAll collects every exception from every failed task into the returned task's Exception property (an AggregateException). But when you await that task, only the first exception is re-thrown. The rest are still there, but you have to look at the task object directly to see them.
First, catch (Exception ex) only sees the first failure because that's what await unwraps. If you want every failure, inspect all.Exception.InnerExceptions on the original task. Second, even after one task fails, WhenAll doesn't cancel the others. It waits for every task to finish (successfully or not) before completing. This is why you can read productTask.Result afterwards even though the batch failed overall.
If you need the full set of exceptions handled together, the idiomatic shape is to catch AggregateException separately or to inspect the task directly:
AggregateException.Flatten() is useful when nested WhenAll calls produce AggregateException instances wrapped inside other AggregateException instances. Flattening pulls every leaf exception up into one flat list.
Task.WhenAll doesn't cancel sibling tasks when one fails. If a fetch is going to take 30 seconds and another already failed, you still pay for the slow one. If you need fail-fast behavior, combine WhenAll with a CancellationTokenSource and cancel it the moment any task faults (the _CancellationToken_ lesson covers cancellation tokens in detail).
A practical question: what happens when one product fetch succeeds and another throws? Two answers, depending on which task you look at.
The aggregating task (the one Task.WhenAll returned) ends up in the Faulted state because at least one of its children faulted. Awaiting it throws. But each input task keeps its own status. The ones that succeeded are RanToCompletion and their .Result is readable. The ones that failed are Faulted and their .Result (or another await) re-throws.
This pattern, "await the batch in a try/catch, then inspect each task to harvest the successes and surface the failures", is the right shape whenever partial failure is acceptable. For a product details page, you might still want to render the product even if reviews couldn't load. The aggregating await tells you _something_ went wrong; the per-task inspection tells you _what_ to show the user.
Note IsCompletedSuccessfully (added in .NET Standard 2.1 / .NET Core 2.0). It's true only when the task finished in the RanToCompletion state. IsCompleted is broader; it's true for any terminal state including Faulted and Canceled, so it's the wrong check here.
Task.WhenAll waits for everything. Task.WhenAny waits for the first thing. Same input (a collection of tasks), different question: instead of "tell me when they're all done", it's "tell me which one finishes first".
Task.WhenAny returns a Task<Task> (or Task<Task<T>> in the generic form). When awaited, it yields the input task that completed first. To get that task's result you await it again, or read its .Result.
The pattern is "race three replicas, keep the first answer, ignore the slower ones". The losing tasks are still running. WhenAny doesn't touch them; it only reports which finished first. If you want them to stop, you need a CancellationToken and the underlying call must honor it.
Task.WhenAny is also exception-aware in a subtle way: it returns the first task to complete, where "complete" includes faulted and canceled. If the fastest replica throws and the slower one succeeds, WhenAny returns the failing one. Awaiting it re-throws. So racing replicas usually means "race until you get a successful answer", not "race until something finishes":
The helper drains the pending list one finisher at a time. The first to finish (us-east) faults, so it's removed and the loop awaits the next. The second finisher (us-west) succeeds, and that's the answer returned. If all of them failed, the loop exhausts and the helper throws.
A useful trick falls out of WhenAny. If you race your real work against a Task.Delay, whichever finishes first tells you whether the work completed in time or not. That's a timeout, expressed entirely in async primitives, with no separate timer object.
Reading the code: WhenAny returns whichever finishes first. If it's the work task, you await it again to surface its result (or its exception). If it's the delay, the work didn't finish in time and you return a fallback. The non-generic Task and the generic Task<string> are compared by reference, which is enough to tell them apart.
One detail to be honest about. This pattern doesn't cancel the work task when the timeout fires. The fetch keeps running until it finishes on its own, even though nobody's waiting for the answer. For real cancellation, the work task needs to accept a CancellationToken and you cancel that token once the timeout wins. The _CancellationToken_ lesson covers exactly that. For now, the timeout pattern is still useful when the "abandoned" work is cheap or short-lived.
.NET 6 added Task.WaitAsync(TimeSpan), which builds the same timeout pattern in:
WaitAsync does exactly the WhenAny-plus-delay dance internally and throws a TimeoutException on timeout. Use it when you have it; the explicit WhenAny pattern is still good to know because it works on older targets and because it generalizes (you can race work against any signal, not just a delay).
Task.Delay allocates a timer registration internally. Creating one timeout per request in a high-throughput service is fine. Creating thousands per second of small (< 50 ms) timeouts can show up in allocation profiles; use WaitAsync or pool the cancellation source in that case.
Racing replicas is a specific instance of a broader pattern: when you have multiple sources that can each answer the same question, fire all of them and keep the first acceptable answer.
A concrete e-commerce example: the product page wants to show stock count, and the inventory service has three regional replicas. Network latency varies. Cross-region calls can spike. Hitting all three and using whichever answers first trades a small amount of extra load for a tighter tail latency.
The first replica to come back wins. Compared to calling one replica and falling back on error, this pattern hides latency variance instead of just hiding failures. The cost is that every call hits every replica, even when one would have been enough. Reserve it for cases where the back-end is cheap enough to over-call and the latency win matters.
This same shape works for any "ask several sources, take the first useful answer" problem: hitting a primary cache plus a secondary store in parallel, asking two pricing engines for the lower quote, or warming multiple shipping-rate providers and keeping the first one to respond.
The most common way to accidentally lose all the parallelism WhenAll gives you is to put await inside the loop that creates the tasks. The intent looks right ("loop over products and fetch each one"), but the await turns the loop body into a barrier.
Five products, 200 ms each, one second total. The await makes each call wait for the previous one. Concurrency is gone.
The fix is to separate "start the tasks" from "wait for the results". Use the loop (or LINQ) to start every task first, then WhenAll once at the end.
One fifth the time. The shape is "project to tasks, then WhenAll". The .ToArray() is important; it forces the LINQ Select to actually call FetchPriceAsync for each item, which is what starts the work. Without it, the tasks would still be lazy and WhenAll would either enumerate them in order (essentially serial) or, worse, enumerate twice and start the work twice.
There's one case where serial awaits in a loop is actually right: when each iteration genuinely depends on the previous one. If you're paginating through API results and need the next page's cursor from the previous page, you have to await each call before issuing the next. The wrong move is to make calls serial when they're independent. Step one is asking, "could these run at the same time?" If yes, fan them out.
A related anti-pattern worth watching for: calling .Result or .Wait() instead of await to "speed things up". These don't run anything in parallel; they block the calling thread on a task that's still doing its work asynchronously. On a single-threaded synchronization context (older WinForms, WPF, classic ASP.NET) this can deadlock outright. On the thread pool it just wastes a thread. Always await. If you need to combine results, use WhenAll.
10 quizzes