Exception Handling in Async
Vaibhav • September 11, 2025
Asynchronous programming makes applications more responsive and scalable, but it also introduces new
challenges-especially when it comes to handling exceptions. In synchronous code, exceptions bubble up the call
stack and can be caught using try-catch
. In asynchronous code, exceptions are
wrapped inside tasks and may not behave the way you expect unless you understand how they propagate.
In this article, we’ll explore how exceptions work in async methods, how to catch and log them properly, and how
to avoid common pitfalls like silent failures and deadlocks. We’ll also look at how exception handling interacts
with await
, Task
, and async void
methods, and how to design your code to be resilient and predictable.
How Exceptions Propagate in Async Code
When an exception is thrown inside an async
method, it doesn’t immediately
crash the application. Instead, the exception is captured and stored in the returned Task
. If the caller uses await
, the exception is
rethrown at the await point.
async Task DoWorkAsync()
{
throw new InvalidOperationException("Something went wrong");
}
async Task RunAsync()
{
try
{
await DoWorkAsync();
}
catch (Exception ex)
{
Console.WriteLine("Caught: " + ex.Message);
}
}
In this example, the exception thrown in DoWorkAsync
is caught in RunAsync
. This works because await
unwraps the
exception and rethrows it.
If you don’t await the task, the exception stays hidden inside the task object. This can lead to unobserved exceptions that are never logged or handled.
Using Task.Result and Task.Wait
If you use Result
or Wait()
to block on a
task, any exception thrown inside the task is wrapped in an AggregateException
.
You must unwrap it manually.
Task t = Task.Run(() => throw new InvalidOperationException("Oops"));
try
{
t.Wait();
}
catch (AggregateException ex)
{
Console.WriteLine("Caught: " + ex.InnerException.Message);
}
This behavior is different from await
, which rethrows the original exception.
Prefer await
whenever possible-it’s cleaner and avoids the need to unwrap.
Async Void and Lost Exceptions
Methods marked async void
are dangerous when it comes to exception handling. If
an exception is thrown inside an async void
method, it cannot be caught by the
caller. The exception is thrown directly on the synchronization context and may crash the application.
async void FireAndForget()
{
throw new Exception("Unhandled");
}
FireAndForget(); // No way to catch this
This is why async void
should only be used for event handlers. For all other
cases, return Task
so the caller can await and handle exceptions properly.
Never use async void
for fire-and-forget methods. Use Task
and document that callers may choose not to await it.
Unobserved Task Exceptions
If a task throws an exception and no one awaits it or accesses its Exception
property, the exception is considered unobserved. In older versions of .NET, this could crash the application.
In modern .NET, unobserved exceptions are ignored unless you attach a handler to TaskScheduler.UnobservedTaskException
.
TaskScheduler.UnobservedTaskException += (sender, e) =>
{
Console.WriteLine("Unobserved: " + e.Exception.Message);
e.SetObserved();
};
This handler lets you log and suppress unobserved exceptions. But the better solution is to always await tasks or handle their exceptions explicitly.
Exception Handling in Parallel Tasks
When you run multiple tasks in parallel using Task.WhenAll
, any exceptions
thrown by the tasks are aggregated. You can catch them using a single try-catch
block.
async Task RunMultipleAsync()
{
Task t1 = Task.Run(() => throw new InvalidOperationException("First"));
Task t2 = Task.Run(() => throw new ArgumentException("Second"));
try
{
await Task.WhenAll(t1, t2);
}
catch (Exception ex)
{
Console.WriteLine("Caught: " + ex.Message);
}
}
Only the first exception is rethrown by await
. To access all exceptions,
inspect the Exception
property of the task:
foreach (var inner in t1.Exception.InnerExceptions)
{
Console.WriteLine(inner.Message);
}
This is useful when you need to log or respond to multiple failures.
Exception Filters and Logging
You can use exception filters to log errors without catching them. This keeps the stack trace intact and avoids swallowing exceptions.
try
{
await Task.Run(() => throw new InvalidOperationException("Oops"));
}
catch (Exception ex) when (Log(ex))
{
// This block is never entered
}
bool Log(Exception ex)
{
Console.WriteLine("Logging: " + ex.Message);
return false;
}
The filter runs before the catch block. If it returns false, the exception continues to propagate. This is a clean way to log errors without interfering with control flow.
Designing Resilient Async Methods
Async methods should be designed to fail gracefully. This means:
- Validating inputs early
- Using
try-catch
around risky operations - Logging exceptions with context
- Returning fallback values when appropriate
async Task<string> LoadConfigAsync()
{
try
{
string json = await File.ReadAllTextAsync("config.json");
return json;
}
catch (IOException ex)
{
Console.WriteLine("Failed to load config: " + ex.Message);
return "{}"; // fallback
}
}
This method handles file errors gracefully and returns a default value. It also logs the error for diagnostics. This pattern is useful in non-critical paths where failure is acceptable.
Avoiding Deadlocks
One of the most common mistakes in async code is blocking on tasks using Result
or Wait()
in a context that requires synchronization (like UI threads). This
can cause a deadlock because the task tries to resume on a thread that’s already blocked.
// Dangerous
string result = GetDataAsync().Result;
// Safe
string result = await GetDataAsync();
Always use await
instead of blocking calls. If you must block, use ConfigureAwait(false)
to avoid capturing the context.
Never use .Result
or .Wait()
in UI or
ASP.NET code. Use await
and
let the runtime manage the context.
Summary
Exception handling in async code requires a shift in mindset. Exceptions are stored in tasks and rethrown at the
await point. You must use await
to catch them properly, avoid async void
outside of events, and be careful with parallel tasks and unobserved
exceptions.
You’ve learned how to catch, log, and propagate exceptions in async methods, how to avoid deadlocks, and how to design resilient code that fails gracefully. In the next article, we’ll explore Cancellation Tokens, which allow you to abort long-running async operations and build responsive, cooperative cancellation into your applications.