Back to Blog
C#

C# async try catch: Handling Exceptions in Async Methods

c# async try catch: Learn how to use try-catch with async methods in C#: exception propagation, async void pitfalls, exception filters, and practical patterns.

asyncexception-handlingtry-catchawaitcsharp
Illustration of a try-catch block wrapping an async operation in C# with an await expression.

c# async try catch requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

The Core Problem: Exceptions in Async Code

When you write a try/catch block around an await expression, the compiler generates code that captures the exception from the returned Task and rethrows it at the point of the await. This means the familiar pattern works:

try { var result = await SomeAsyncOperation(); } catch (Exception ex) { LogError(ex); }

The exception thrown inside SomeAsyncOperation is stored inside the Task that the method returns. When you await that task, the runtime rethrows the exception at the await point, and the catch block handles it. This is the expected behavior, and it works for most async methods.

However, there are subtle details that can trip up even experienced developers. The way exceptions propagate depends on whether the async method returns Task, Task<T>, or void. The placement of the try block relative to the await also matters. Understanding these details is essential for writing reliable error handling in async code.

How Exceptions Propagate in Async Methods

An async method that returns Task or Task<T> captures any exception that escapes the method body and stores it in the returned task. If the caller never awaits that task, the exception is silently swallowed. The runtime does not raise an unhandled exception for a task that is not observed.

Consider this code:

public async Task DoWorkAsync() { throw new InvalidOperationException("Something went wrong"); } // Caller: var task = DoWorkAsync(); // No exception thrown here // The exception is stored in task, but nobody awaits it.

If you later await the task, the exception is rethrown:

try { await task; } catch (InvalidOperationException ex) { // Handled }

But if you never await or inspect the task, the exception is lost. This is why it's important to always await or observe tasks that can fail. The compiler warns about unobserved tasks in some analyzers, but it's not a compile-time error.

When an async method itself contains a try/catch around an await, the catch block runs in the context of the async method. The exception is caught at the point where the await rethrows it, and the rest of the method continues normally. This is the same as synchronous exception handling, except the exception is delivered through the task.

Handling Exceptions in async void vs async Task

One of the most important distinctions is between async void and async Task. An async void method cannot be awaited by the caller. Exceptions thrown inside an async void method are not captured in a task; instead, they are raised on the synchronization context that started the method, often crashing the application.

async void Button_Click(object sender, EventArgs e) { try { await LoadDataAsync(); } catch (Exception ex) { // This catch will handle exceptions from LoadDataAsync. } }

In this example, the try/catch works because the await is inside the method. The exception is caught at the await point. However, if an exception is thrown outside the try block—for example, if LoadDataAsync itself throws before returning a task—the exception will be unhand and can crash the process. More commonly, developers use async void for event handlers, and they must be extremely careful to catch all exceptions inside the method because there is no way for the caller to observe them.

For methods that return Task, the caller can catch exceptions by awaiting the task. This is the recommended approach for most async code. Prefer async Task over async void unless you are writing an event handler that requires void.

Using Exception Filters with Async Code

C# exception filters allow you to specify a condition that determines whether a catch block should handle an exception. This works with async code exactly as it does with synchronous code. The filter runs before the catch block, and it can inspect the exception or any other state.

try { await FetchDataAsync(); } catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { // Handle 404 specifically } catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.InternalServerError) { // Handle 500 specifically }

The filter is evaluated in the context of the async method, and it can access variables from the surrounding scope. This is useful for distinguishing between error types without catching all exceptions. Note that the filter itself should not throw; if it does, the exception is ignored and the next catch block is considered.

Exception filters are particularly useful in async code because they allow you to keep the try block small and handle specific conditions without rethrowing. However, they add a small performance overhead, so use them when the clarity is worth it.

Common Pitfalls: Swallowing Exceptions and Empty Catch

A frequent mistake is to catch an exception and do nothing with it, effectively swallowing it. This hides errors and makes debugging difficult. For example:

try { await SaveAsync(); } catch (Exception) { // Do nothing }

This is almost always wrong. At the very least, you should log the exception. If you cannot handle the exception, rethrow it so the caller can decide what to do. In async code, rethrowing is done with throw; inside the catch block.

Sometimes developers catch an exception, log it, and then return a default value. That is acceptable if the method is designed to degrade gracefully, but it must be intentional. The key is to avoid silent failures that make production issues impossible to trace.

Another pitfall is catching exceptions that you cannot handle. For example, catching Exception and then trying to continue when the underlying operation is in an invalid state. Always consider whether the method can continue safely after the exception.

Performance Considerations: Exception Cost and Task Allocation

Exceptions are expensive in .NET. Throwing an exception involves allocating an exception object, capturing a stack trace, and unwinding the call stack. In async code, there is an additional cost because the exception is stored in the Task and then rethrown at the await point. This means that an exception in an async method may be slightly more expensive than in a synchronous method, but the dominant cost is still the exception itself.

Avoid using exceptions for control flow. For example, do not throw an exception to indicate that a value is missing; use Try-pattern methods or return null or a result object. When you do need to handle exceptions, catch them at the appropriate level and avoid catching exceptions that you don't need to handle.

Another consideration is that unobserved task exceptions are not immediately raised. They are eventually finalized by the runtime, which can cause delayed crashes or lost errors. Always ensure that tasks are awaited or have a continuation that observes exceptions.

Practical Patterns for Async Error Handling

A common pattern is to wrap a series of await calls in a single try block, but this can make it hard to know which operation failed. Instead, consider handling exceptions per operation when you need different behavior.

For retry logic, you can use a helper method that catches transient exceptions and retries:

public static async Task<T> WithRetryAsync<T>(Func<Task<T>> action, int retryCount = 3) { for (int i = 0; i < retryCount; i++) { try { return await action(); } catch (Exception ex) when (IsTransient(ex)) { if (i == retryCount - 1) { throw; } await Task.Delay(TimeSpan.FromMilliseconds(100 * (i + 1))); } } throw new InvalidOperationException("Unreachable code"); }

This pattern centralizes retry logic and keeps the calling code clean. The when filter ensures that only transient exceptions trigger a retry. If the exception is not transient, it propagates immediately.

Another pattern is to use a try/catch/finally block to ensure cleanup happens even when an exception occurs. The finally block runs after the try and any catch blocks, and it is the right place to release resources or reset state.

try { await using (var stream = new FileStream(...)) { await stream.WriteAsync(...); } } catch (IOException ex) { LogError(ex); }

The await using pattern works with IAsyncDisposable and ensures that disposal is asynchronous and exception-safe.

c# async try catch: Practical Usage and Code Examples | RYUSLOG DEV