Handling Exceptions in C# Async Methods with try/catch
Learn how to use try/catch with async methods in C#: exception propagation, async void risks, exception filters, and practical retry and cleanup patterns.
In C#, try/catch works around await expressions just as it does around synchronous code. When an async method that returns Task or Task<T> throws, the exception is captured in the returned task. When you await that task, the runtime rethrows the stored exception at the await point, so a surrounding catch block can handle it.
How Exceptions Propagate in Async Methods
An async method that returns Task or Task<T> captures an exception that escapes the method body and stores it in the returned task. If the caller never awaits or otherwise observes the task, the exception is not raised immediately. Depending on the runtime, it may later be surfaced through TaskScheduler.UnobservedTaskException when the task is collected, or it may be effectively lost. Always await tasks that can fail, or attach a continuation that observes the exception.
Consider this code:
public async Task DoWorkAsync() { throw new InvalidOperationException("Something went wrong"); } // Caller: var task = DoWorkAsync(); // No exception is thrown here; the fault is stored in task. // If the caller never awaits or observes task, the exception may be lost.
If you later await the task, the exception is rethrown:
try { await task; } catch (InvalidOperationException ex) { // Handled }
When an async method has a try/catch around an await, the catch block runs in the async method after the awaited task fault is delivered. After the catch block completes, the method continues just as it would after a synchronous catch.
Handling Exceptions in async void vs async Task
The most important exception-handling distinction in async code is between async void and async Task. An async void method cannot be awaited by its caller. If an exception escapes an async void method, it is not captured in a Task; instead it is raised on the current SynchronizationContext (or the thread pool when there is no context), and it can crash the application.
async void Button_Click(object sender, EventArgs e) { try { await LoadDataAsync(); } catch (Exception ex) { // Handles exceptions from LoadDataAsync. } }
In this sample, the try/catch is inside the async void method, so it can catch the fault from await LoadDataAsync(). But any exception thrown outside that try block has no Task to carry it, and the caller has no way to observe it. If an async void method can fail, keep the entire method body inside the try block or handle every possible failure path inside the method.
Prefer async Task over async void except for event handlers or other APIs that require void. With async Task, the caller can use try/catch around the await to observe exceptions.
Using Exception Filters with Async Code
C# exception filters let you specify a condition that controls whether a catch block handles an exception. They work with await in the same way they work with synchronous code:
try { await FetchDataAsync(); } catch (ApiException ex) when (ex.StatusCode == 404) { // Handle 404 specifically } catch (ApiException ex) when (ex.StatusCode == 500) { // Handle 500 specifically }
This example assumes an application-specific exception type such as ApiException exposes the status code you need. Filters can inspect the exception and other state in the surrounding scope. Keep filter expressions simple and avoid throwing from them; an exception thrown inside a filter can make error handling much harder to reason about.
Common Pitfalls: Swallowing Exceptions and Empty Catch
Catching an exception and doing nothing with it is almost always wrong. It hides failures and makes production issues hard to trace:
try { await SaveAsync(); } catch (Exception) { // Do nothing }
At minimum, log the exception. If the current method cannot handle the exception, rethrow it with throw; so the caller can decide what to do. Returning a default value may be acceptable if the method is intentionally designed to degrade gracefully, but the decision should be explicit.
Another pitfall is catching an exception and continuing as if the underlying operation is still in a valid state. Before swallowing an exception or returning a fallback value, consider whether the method can continue safely.
Performance Considerations: Exception Cost and Unobserved Tasks
Exceptions are expensive in .NET because throwing involves allocating an exception object, capturing stack information, and unwinding the stack. In async code, a fault also has to be stored in the Task and later rethrown at the await point, so the error path contains additional allocation and bookkeeping. Avoid using exceptions for control flow; prefer Try-pattern methods, null, or result objects for expected failure cases.
Unobserved faulted tasks can also be a problem. If a task is never awaited or observed, its exception is not reported immediately and may only surface later (or not at all). Do not rely on TaskScheduler.UnobservedTaskException as a safety net; make sure tasks are awaited or observed in normal code paths.
Practical Patterns for Async Error Handling
Wrapping several await calls in one large try block can make it unclear which operation failed. When different failures need different handling, catch around individual operations instead.
For transient failures, a small retry helper can keep the calling code clean:
public static async Task<T> WithRetryAsync<T>(Func<Task<T>> action, int retryCount = 3) { for (int attempt = 0; ; attempt++) { try { return await action(); } catch (Exception ex) when (IsTransient(ex)) { if (attempt >= retryCount - 1) { throw; } await Task.Delay(TimeSpan.FromMilliseconds(100 * (attempt + 1))); } } }
The when filter in this helper limits retries to transient exceptions. Non-transient exceptions are not caught by this catch, so they propagate immediately. After the last allowed attempt, throw; rethrows the original transient exception.
Use try/catch/finally when cleanup must happen regardless of success or failure. For async disposables, await using works with IAsyncDisposable and makes cleanup exception-safe:
byte[] bytes = Encoding.UTF8.GetBytes("data"); string path = "example.txt"; try { await using var stream = new FileStream(path, FileMode.Create); await stream.WriteAsync(bytes, 0, bytes.Length); } catch (IOException ex) { LogError(ex); }
A finally block (or await using) is the right place to release resources or reset state after an async operation.