Handling Exceptions in C# Async Methods
Handling exceptions in asynchronous C# code is essential for building
stable applications, yet it differs significantly from traditional
synchronous error handling. This article covers how to properly catch
exceptions using standard try-catch blocks with the
await keyword, explains why async void should
be avoided, demonstrates how to capture all exceptions from multiple
concurrent operations using Task.WhenAll, and addresses
background unobserved task exceptions.
Using Standard Try-Catch with Await
When you await a Task or
Task<T>, the runtime automatically unwraps any thrown
AggregateException and rethrows the first actual exception.
This allows you to use standard try-catch blocks just as
you would in synchronous code.
public async Task FetchDataAsync()
{
try
{
await CallExternalServiceAsync();
}
catch (HttpRequestException ex)
{
// Handles the specific unwrapped exception
Console.WriteLine($"Network error: {ex.Message}");
}
catch (Exception ex)
{
// Handles any other unexpected exceptions
Console.WriteLine($"General error: {ex.Message}");
}
}If you do not use await and instead access
.Result or call .Wait(), the runtime will not
unwrap the exception, forcing you to catch
AggregateException directly. Always prefer
await over blocking calls to keep the code clean and
prevent thread pool starvation.
The Danger of Async Void
Methods declared as async void cannot be awaited.
Consequently, exceptions thrown inside an async void method
cannot be caught by an external caller's try-catch block.
Unhandled exceptions in async void methods are raised
directly on the synchronization context, typically terminating the
process.
- Rule: Always return
TaskorTask<T>instead ofvoidfor asynchronous methods. - Exception: Asynchronous event handlers are the only
place where
async voidis necessary. In those cases, you must enclose the entire body of the event handler in an internaltry-catchblock:
private async void OnButtonClicked(object sender, EventArgs e)
{
try
{
await ProcessActionAsync();
}
catch (Exception ex)
{
// Log or show error to user; unhandled exceptions here will crash the app
LogError(ex);
}
}Catching Multiple Exceptions with Task.WhenAll
When running multiple tasks concurrently with
Task.WhenAll, more than one task can fail. If you simply
await Task.WhenAll(...), the await statement
will only rethrow the first exception that occurred. The
remaining exceptions will be hidden from the catch
block.
To inspect all exceptions from all faulted tasks, hold a reference to the combined task:
public async Task ProcessMultipleTasksAsync()
{
Task task1 = Task.Run(() => throw new InvalidOperationException("Error 1"));
Task task2 = Task.Run(() => throw new ArgumentException("Error 2"));
Task allTasks = Task.WhenAll(task1, task2);
try
{
await allTasks;
}
catch
{
// allTasks.Exception contains the AggregateException with all errors
if (allTasks.Exception != null)
{
foreach (var innerException in allTasks.Exception.InnerExceptions)
{
Console.WriteLine($"Caught: {innerException.GetType().Name} - {innerException.Message}");
}
}
}
}Handling Unobserved Task Exceptions
If a task faults and is never awaited, its exception remains stored
within the Task object. When that task is eventually
garbage-collected, the finalizer detects the unhandled error and raises
the TaskScheduler.UnobservedTaskException event.
You can subscribe to this event to log background failures that escaped standard error handling:
TaskScheduler.UnobservedTaskException += (sender, eventArgs) =>
{
Console.WriteLine($"Unobserved exception: {eventArgs.Exception.Message}");
// Prevents the exception from triggering escalation policy
eventArgs.SetObserved();
};Relying on this event should be a last resort. Every asynchronous operation should be awaited or explicitly observed within its execution flow.