C# Async Exception Handling: Patterns That Work
c# async exception handling: Learn how to catch and handle exceptions in C# async methods, including Task.WhenAll, async void, and cancellation scenarios.
C# async exception handling differs from synchronous code because exceptions are stored in the returned Task rather than thrown directly on the call stack. When you await a task, the exception is rethrown at the await point, but if you never await the task, the exception may be silently ignored. Understanding this behavior is essential for writing reliable async code.
Why Async Exception Handling Behaves Differently
In a synchronous method, an exception propagates up the call stack immediately. In an async method, the compiler transforms the method into a state machine that returns a Task or Task<T> to the caller as soon as an await point is reached. If an exception occurs before the first await, it is captured and stored on the returned task. If it occurs after an await, it is also captured on the task. The caller only observes the exception when it awaits the task or explicitly accesses the task's Exception property.
This design means that a simple try-catch inside the async method works as expected, but a try-catch around an async call without await will not catch the exception. For example:
public async Task DoWorkAsync() { await Task.Delay(100); throw new InvalidOperationException("Failed"); } // Caller public void CallWithoutAwait() { try { var task = DoWorkAsync(); // Exception is not thrown here // No await, so no exception is caught } catch (InvalidOperationException) { // This never executes } }
To catch the exception, you must await the task inside the try block, or use task.GetAwaiter().GetResult() in a synchronous context (though that blocks the thread). The correct pattern is to await within the try.
Catching Exceptions in an async Task Method
The most straightforward way to handle exceptions in an async method is to wrap the await expression in a try-catch block. This works for both Task and Task<T> return types. The exception is caught at the point where the awaited task fails, and you can handle it locally.
public async Task ProcessAsync() { try { await Task.Delay(100); throw new InvalidOperationException("Processing failed"); } catch (InvalidOperationException ex) { // Log and handle Console.WriteLine($"Caught: {ex.Message}"); } }
When you need to catch exceptions from multiple awaited operations, you can place multiple await statements inside the same try block. The first exception that occurs will be caught. If you need to catch different exception types, use multiple catch blocks, just as in synchronous code.
A common mistake is to catch Exception and then rethrow a new exception, losing the original stack trace. Use throw; to preserve the original exception, or include the original as the inner exception when wrapping.
The Danger of async void and Event Handlers
async void methods are the exception to the rule. They are designed for event handlers where the caller does not expect a Task. However, exceptions in async void methods are not captured on a task; they are rethrown on the current SynchronizationContext or thread pool, which can crash the application if unhandled. This makes async void unsuitable for any method other than event handlers, and even then you must handle exceptions carefully inside the method.
// Dangerous: exception escapes and may crash the app async void Button_Click(object sender, EventArgs e) { await Task.Delay(100); throw new InvalidOperationException("Boom"); }
If you must use async void, wrap the entire body in a try-catch and log or handle the exception without rethrowing. Do not rely on the caller to catch it because there is no caller task.
Handling Exceptions from Multiple Tasks
When you start several tasks and await them with Task.WhenAll, the behavior is subtle. Task.WhenAll returns a task that completes when all tasks complete. If any task faults, the returned task faults, and awaiting it throws the first exception that occurred. However, the other exceptions are not lost; they are stored in the Task.Exception property as an AggregateException. To observe all exceptions, you can catch AggregateException and inspect its inner exceptions.
var task1 = Task.Run(() => throw new InvalidOperationException("First")); var task2 = Task.Run(() => throw new ArgumentException("Second")); try { await Task.WhenAll(task1, task2); } catch (AggregateException ex) { foreach (var inner in ex.InnerExceptions) { Console.WriteLine(inner.Message); } }
In .NET 4.0 and later, await unwraps the first inner exception by default, so the catch block above catches the first exception, not the AggregateException. To catch the aggregate, you need to access task.Exception or use task.WhenAll(...).ContinueWith with TaskContinuationOptions.OnlyOnFaulted. A more practical approach is to handle each task individually or use Task.WhenAll and then inspect each task's status.
For example, you can await each task separately in a loop, which allows you to catch each exception independently:
var tasks = new[] { task1, task2 }; foreach (var task in tasks) { try { await task; } catch (Exception ex) { Console.WriteLine(ex.Message); } }
This pattern is clearer when you need to handle failures per task rather than as a group.
Exception Handling in Async Streams
Async streams (IAsyncEnumerable<T>) also have special exception handling. Exceptions thrown during the enumeration of an async stream are delivered to the consumer when the MoveNextAsync returns a faulted ValueTask<bool>. You can catch them in a try-catch around the await foreach loop.
await foreach (var item in GetItemsAsync()) { // Process item }
If the producer throws an exception, the consumer's await foreach will throw that exception. You can catch it just like any other exception. However, the producer must ensure that the exception is thrown after the last yield return, or it will be delivered on the next MoveNextAsync call. The compiler handles this automatically, but you should be aware that exceptions from async streams are not wrapped in AggregateException unless you explicitly throw one.
Rethrowing and Logging Without Losing the Original Error
When you catch an exception in an async method, you often need to log it and either handle it or rethrow it. The key is to preserve the original stack trace. Use throw; to rethrow the same exception without modifying the stack trace. If you need to add context, wrap the exception in a new exception and set the original as the InnerException.
catch (Exception ex) { Log.Error(ex, "Operation failed"); throw new ApplicationException("Operation failed", ex); }
Avoid catching Exception and then throwing a new exception without setting the inner exception, because that loses the original failure details. Also avoid swallowing exceptions entirely unless you have a specific reason, such as a fallback strategy. Swallowing exceptions in async code can lead to silent failures that are difficult to diagnose.
Performance and Operational Considerations
Exception handling in async code has a small performance cost, but it is not usually the bottleneck. The cost comes from the exception object creation and stack trace capture. In high-throughput async paths, avoid using exceptions for control flow. Instead, use result objects or Task-based patterns that signal failure without throwing.
From an operational perspective, unhandled exceptions in async methods can crash the process if they occur on a thread pool thread without a SynchronizationContext. In ASP.NET Core, unhandled exceptions in background tasks can bring down the application. Always ensure that async methods that run in the background have a top-level try-catch that logs the error and prevents the process from terminating.
For logging, use the exception's ToString() method to capture the full stack trace, and include the Task.Id or correlation ID to trace the failure across async boundaries. This is especially important when multiple tasks run concurrently and fail independently.
Cancellation and Finally Blocks in Async Methods
Cancellation is closely related to exception handling. When a CancellationToken is canceled, an OperationCanceledException is thrown. This exception is often caught separately from other exceptions because it represents a normal flow interruption rather than an error. In async methods, you should catch OperationCanceledException and handle it appropriately, typically by rethrowing it if the method is expected to propagate cancellation.
public async Task ProcessAsync(CancellationToken cancellationToken) { try { await Task.Delay(100, cancellationToken); } catch (OperationCanceledException) { // Clean up and rethrow throw; } }
finally blocks work in async methods just as in synchronous ones. They execute after the try and catch blocks, regardless of whether an exception was thrown. Use finally to release resources, such as disposing a SemaphoreSlim or closing a network connection. Be careful not to throw from a finally block because it will override any exception that is currently propagating.
A common pattern is to use try-finally without a catch to ensure cleanup happens, while letting exceptions propagate. This is safe in async methods as long as the finally block does not contain await that can throw. If you need to await in a finally block, use the await using pattern or ensure the awaited operation does not throw.
Handling exceptions in async code requires understanding where the exception is stored and how it is rethrown. By using await inside try-catch, avoiding async void except for event handlers, and carefully managing multiple tasks, you can build robust async systems that fail predictably and are easy to debug.