WhenAll aggregates, await unwraps
Task.WhenAll returns a single task that completes when all the supplied tasks have completed. If any of them faulted, the returned task faults, and its Exception property is an AggregateException whose InnerExceptions collection contains every failure. That is the complete picture of what happened, and it is available.
await then throws away most of it. When you await a faulted task, the compiler-generated code does not hand you the AggregateException; it takes the first inner exception and rethrows it with its original stack trace preserved. That design is deliberate and mostly a kindness — it means catch (HttpRequestException) works the way you would want on a single awaited call, instead of forcing you to unwrap an aggregate every time. The cost is that on WhenAll the same mechanism silently discards the other failures.
var a = CallA();
var b = CallB();
var c = CallC();
var all = Task.WhenAll(a, b, c);
try
{
await all;
}
catch (Exception ex)
{
foreach (var inner in all.Exception!.InnerExceptions) Log(inner);
}
Two details in that trace are worth being precise about. First, "the first inner exception" is first in the order WhenAll assembled them, which follows the order of the tasks you passed in rather than the order in which they failed — so with b failing at 50 ms and a at 100 ms, the exception you catch is still a's TimeoutException. Second, all.Exception is only reachable because the task is in a variable. Written as await Task.WhenAll(a, b, c) there is nothing left to interrogate, and the second failure is unrecoverable from inside the catch.
Why a lost exception here is not also a crash
Reading each faulted task's exception to build the aggregate counts as observing it, so the individual tasks do not later raise unobserved-exception events. That is what makes this quiet. You lose the information, not the process.
The contrast is Task.WhenAny, which completes as soon as the first task completes and does nothing at all with the others. Those losers keep running, and if one of them faults later with nobody awaiting it, its exception is unobserved. Since .NET Framework 4.5, an unobserved task exception does not terminate the process: the runtime raises TaskScheduler.UnobservedTaskException when the task is finalised and then swallows it. So WhenAny used for a timeout race, which is its most common use, routinely creates failures that are invisible unless you subscribe to that event or attach a continuation to the losers. On older behaviour this would have crashed the process and you would have known.
Cancellation is a third outcome, not a fault
WhenAll resolves to one of three states and the precedence matters. If any task faulted, the returned task is faulted. If none faulted but at least one was cancelled, the returned task is cancelled, and awaiting it throws OperationCanceledException — a catch (Exception) will see that too, and code that logs everything it catches as an error will report a client hanging up as a service failure.
The practical consequence is that a broad catch around a WhenAll conflates three genuinely different situations: real upstream failures, a cancellation you asked for, and a cancellation you did not. Handle OperationCanceledException separately and check the token you passed, so that "the caller went away" is logged as information and "the upstream timed out" is logged as an error.
The failures you did not catch are still side effects
The exception plumbing is the visible half of this question and the smaller half. Three parallel calls with two failures means one of them succeeded, and if these are writes rather than reads, your system is now in a state no single request intended: an order created without a payment authorisation, a user provisioned in one downstream and not the other.
WhenAll gives you no transaction and no rollback, so the compensation is yours to design. The options are the usual ones and they should be named explicitly: make each operation idempotent so the whole set can be retried safely, or record intent durably first and let a worker drive each part to completion, or accept the partial state and reconcile it asynchronously. What is not acceptable is catching the first exception, returning a 500, and leaving the one write that succeeded unmentioned, because that write is now permanent and nothing in your logs says so.
The habits that follow
Keep the WhenAll task in a variable whenever more than one failure is worth knowing about, and log the aggregate rather than the rethrown exception. Give every parallel call a timeout and a token so WhenAll cannot wait on something that never completes — one task that hangs forever makes the whole group hang forever, and no amount of exception handling saves you from that.
Where you genuinely want every outcome rather than the first error, stop using exceptions for control flow across the set: have each operation return a result value, await WhenAll over tasks that do not throw, and inspect the results. That inverts the default, so partial failure becomes data you must handle rather than an exception you might accidentally flatten to one line.
WhenAll collects every failure and await hands you exactly one of them, so the exception you catch is a sample rather than a summary — and the successful task in a failed batch is a state change you now own.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).