Introduction
In C#, exceptions thrown inside async methods are captured and wrapped in the returned Task rather than thrown immediately. When you await that task, the runtime unwraps the exception and rethrows the original exception. However, if you access Task.Result or Task.Wait() instead, the exception is wrapped in an AggregateException. Understanding this wrapping behavior is essential for writing correct error handling in async code.
How Async Exceptions Work
1async Task DoWorkAsync()
2{
3 throw new InvalidOperationException("Something went wrong");
4}
When DoWorkAsync() is called, the exception does not propagate immediately. Instead, it is stored in the returned Task object. How you observe the task determines what exception you see.
await Unwraps the Exception
1try
2{
3 await DoWorkAsync();
4}
5catch (InvalidOperationException ex)
6{
7 // Catches the original exception directly
8 Console.WriteLine(ex.Message); // "Something went wrong"
9}
await unwraps the first exception from the Task and rethrows it as-is. This is the recommended pattern — it behaves like synchronous exception handling.
Task.Wait() and Task.Result Wrap in AggregateException
1try
2{
3 DoWorkAsync().Wait(); // Synchronous blocking — avoid this
4}
5catch (AggregateException ex)
6{
7 // The original exception is nested inside AggregateException
8 Console.WriteLine(ex.InnerException?.Message); // "Something went wrong"
9}
10
11try
12{
13 var result = SomeAsyncMethod().Result; // Also wraps in AggregateException
14}
15catch (AggregateException ex)
16{
17 foreach (var inner in ex.InnerExceptions)
18 {
19 Console.WriteLine(inner.GetType().Name + ": " + inner.Message);
20 }
21}
Task.Wait() and Task.Result throw AggregateException because a Task can represent multiple operations (e.g., Task.WhenAll), each of which might fail independently.
Multiple Exceptions with Task.WhenAll
1async Task FailOne() => throw new InvalidOperationException("Fail 1");
2async Task FailTwo() => throw new ArgumentException("Fail 2");
3
4try
5{
6 await Task.WhenAll(FailOne(), FailTwo());
7}
8catch (InvalidOperationException ex)
9{
10 // await only rethrows the FIRST exception
11 Console.WriteLine(ex.Message); // "Fail 1"
12}
13
14// To see ALL exceptions, capture the task first
15var task = Task.WhenAll(FailOne(), FailTwo());
16try
17{
18 await task;
19}
20catch
21{
22 // task.Exception is an AggregateException with both failures
23 foreach (var ex in task.Exception!.InnerExceptions)
24 {
25 Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
26 }
27 // InvalidOperationException: Fail 1
28 // ArgumentException: Fail 2
29}
Exception Filters
Use exception filters to catch specific async exceptions:
1try
2{
3 await CallExternalApiAsync();
4}
5catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
6{
7 Console.WriteLine("Resource not found");
8}
9catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
10{
11 Console.WriteLine("Authentication required");
12}
13catch (Exception ex)
14{
15 Console.WriteLine($"Unexpected error: {ex.Message}");
16}
Exception filters (when) do not unwind the stack, preserving the original call stack for debugging.
Preserving Stack Traces
1try
2{
3 await DoWorkAsync();
4}
5catch (Exception ex)
6{
7 // WRONG — rethrow loses the original stack trace
8 // throw ex;
9
10 // CORRECT — preserves the original stack trace
11 throw;
12}
13
14// Or use ExceptionDispatchInfo for manual rethrowing
15using System.Runtime.ExceptionServices;
16
17ExceptionDispatchInfo? capturedException = null;
18try
19{
20 await DoWorkAsync();
21}
22catch (Exception ex)
23{
24 capturedException = ExceptionDispatchInfo.Capture(ex);
25}
26
27// Later, rethrow with original stack trace
28capturedException?.Throw();
async void Exception Behavior
1// DANGEROUS — exceptions crash the process
2async void FireAndForget()
3{
4 throw new Exception("This crashes the app!");
5 // Exception goes to SynchronizationContext, not the caller
6}
7
8// SAFE — return Task so the caller can handle exceptions
9async Task FireAndForgetSafe()
10{
11 throw new Exception("This can be caught by the caller");
12}
13
14// If you must use async void (event handlers), catch internally
15async void Button_Click(object sender, EventArgs e)
16{
17 try
18 {
19 await DoWorkAsync();
20 }
21 catch (Exception ex)
22 {
23 MessageBox.Show(ex.Message);
24 }
25}
async void methods cannot be awaited, so their exceptions propagate to the SynchronizationContext and typically crash the application.
1try
2{
3 // ConfigureAwait(false) does not change exception behavior
4 var result = await DoWorkAsync().ConfigureAwait(false);
5}
6catch (InvalidOperationException ex)
7{
8 // Still catches the original exception
9 // But the catch block runs on a thread pool thread, not the UI thread
10 Console.WriteLine(ex.Message);
11}
Common Pitfalls
Using .Result or .Wait(): These wrap exceptions in AggregateException and can cause deadlocks in UI or ASP.NET contexts. Always use await instead.
async void methods: Exceptions in async void cannot be caught by the caller. They crash the process unless caught inside the method. Only use async void for event handlers.
Swallowing exceptions with Task.WhenAll: await Task.WhenAll(...) only rethrows the first exception. Capture the task and inspect task.Exception.InnerExceptions to see all failures.
throw ex instead of throw: throw ex resets the stack trace, making it impossible to find where the exception originally occurred. Always use bare throw to rethrow.
Catching AggregateException when using await: With await, you never see AggregateException — the original exception is unwrapped. Only catch AggregateException when using .Wait() or .Result.
Summary
await unwraps exceptions from tasks and rethrows the original — use this pattern
Task.Wait() and Task.Result wrap exceptions in AggregateException — avoid these
Task.WhenAll captures multiple exceptions but await only rethrows the first
async void methods cannot propagate exceptions to callers — only use for event handlers
Use throw (not throw ex) to preserve stack traces when rethrowing
Use ExceptionDispatchInfo.Capture() when you need to store and rethrow later