C#
Async Programming
Task.WhenAll
Exception Handling
AggregateException

Why doesn't await on Task.WhenAll throw an AggregateException?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Many developers expect await Task.WhenAll(...) to throw AggregateException, but await follows different exception semantics. The language unpacks task failures to keep async code readable and close to synchronous try and catch flow. The aggregate is still present on the task object, but not always thrown directly by await.

How await Handles Exceptions

await observes the awaited task and rethrows failures in an unwrapped form when possible.

csharp
1using System;
2using System.Threading.Tasks;
3
4static async Task FailAsync()
5{
6    await Task.Delay(10);
7    throw new InvalidOperationException("single failure");
8}
9
10static async Task DemoAsync()
11{
12    try
13    {
14        await FailAsync();
15    }
16    catch (Exception ex)
17    {
18        Console.WriteLine(ex.GetType().Name); // InvalidOperationException
19    }
20}

This behavior is by design so ordinary async error handling does not require aggregate unwrapping in common cases.

What Task.WhenAll Produces

Task.WhenAll returns one task that completes when all inner tasks complete. If multiple inner tasks fail, the returned task stores all failures in its Exception.InnerExceptions collection.

csharp
1using System;
2using System.Linq;
3using System.Threading.Tasks;
4
5static async Task<int> FailA() { await Task.Delay(20); throw new Exception("A failed"); }
6static async Task<int> FailB() { await Task.Delay(40); throw new Exception("B failed"); }
7
8static async Task ShowAllFailuresAsync()
9{
10    var allTask = Task.WhenAll(FailA(), FailB());
11
12    try
13    {
14        await allTask;
15    }
16    catch (Exception ex)
17    {
18        Console.WriteLine($"await threw: {ex.Message}");
19
20        var all = allTask.Exception?.InnerExceptions ?? Enumerable.Empty<Exception>();
21        foreach (var e in all)
22        {
23            Console.WriteLine($"inner: {e.Message}");
24        }
25    }
26}

So the aggregate exists, but await surfaces a simplified throw path.

Why This Design Is Useful

If every awaited failure always threw AggregateException, most async catch blocks would include boilerplate unwrapping logic for single-failure operations. The current model optimizes everyday readability while still allowing advanced inspection.

You get two levels:

  • simple thrown exception from await for primary control flow,
  • full failure list from the stored aggregate when needed.

Difference From .Wait() And .Result

Blocking APIs use older task consumption semantics.

csharp
1try
2{
3    Task.WhenAll(FailA(), FailB()).Wait();
4}
5catch (AggregateException ae)
6{
7    foreach (var e in ae.InnerExceptions)
8    {
9        Console.WriteLine(e.Message);
10    }
11}

This is one reason async codebases should avoid .Wait() and .Result on asynchronous tasks.

Cancellation And Mixed Outcomes

When tasks include cancellation and faults together, final status can be faulted or canceled depending on combined outcomes. For incident analysis, capture:

  • overall task status,
  • first thrown exception observed by await,
  • full InnerExceptions list when present,
  • cancellation token state.

This gives complete telemetry for parallel workflows.

Practical Logging Pattern

In service code, preserve the WhenAll task reference, then log all inner failures in catch block.

csharp
1var allTask = Task.WhenAll(tasks);
2try
3{
4    await allTask;
5}
6catch
7{
8    var errors = allTask.Exception?.InnerExceptions;
9    // log each error with correlation id
10    throw;
11}

This pattern avoids losing secondary failures.

Unit Testing This Behavior

When documenting team conventions, add tests that assert both the thrown exception from await and the full InnerExceptions list on the aggregate task. This prevents future refactors from silently reducing error visibility in parallel execution paths.

Common Pitfalls

  • Assuming await hides additional failures from Task.WhenAll.
  • Discarding WhenAll task reference and losing aggregate inspection path.
  • Using .Result in async paths and introducing aggregate wrapping plus deadlock risk.
  • Logging only first observed exception in parallel workloads.
  • Ignoring cancellation state when diagnosing mixed outcomes.

Summary

  • 'await favors readable exception flow and often rethrows a single exception type directly.'
  • 'Task.WhenAll still captures all failures in aggregate task state.'
  • Keep a reference to the returned task to inspect InnerExceptions.
  • Prefer await over blocking task consumption APIs.
  • Log aggregated failures explicitly when debugging parallel task orchestration.

Course illustration
Course illustration

All Rights Reserved.