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.