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 Task or Task<T> instead of void for asynchronous methods.
  • Exception: Asynchronous event handlers are the only place where async void is necessary. In those cases, you must enclose the entire body of the event handler in an internal try-catch block:
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.