CodeGym /Courses /C# SELF /Exception handling in asynchronous code

Exception handling in asynchronous code

C# SELF
Level 59 , Lesson 4
Available

1. Asynchronous methods and exceptions

We're used to the idea that if something bad happens in code (for example, division by zero or trying to access a non-existent file), an exception is thrown that we can catch with try-catch. It's simple as long as code runs sequentially and on a single thread. But once asynchrony appears, the world starts to feel like outer space: an exception can "show up" far away from where you expected it, or remain unnoticed entirely.

The reason is that an async method often returns a Task (Task) whose execution continues AFTER the method returns. An exception can occur after the main thread has "let go" of execution and moved on. So the usual try-catch around calling an async method doesn't always behave like it does in synchronous code.

Let's look at a simple example. Suppose in our mini-app we have this async method:

// Fragment of our app: async "send report" calculation
public async Task SendReportAsync()
{
    // There could be network calls or file access here
    await Task.Delay(100);
    throw new InvalidOperationException("Error while sending the report!");
}

And here's how we might call it:

SendReportAsync();
Console.WriteLine("Continuing work...");

Visualization

flowchart TD
    Start["Main thread"]
    Call[/"Call SendReportAsync()"/]
    Continue["Work continues..."]
    Exception["Exception occurs in Task"]
    Unhandled["Error unhandled!"]
    Start --> Call --> Continue
    Call -.- Exception --> Unhandled

Conclusion: if an async method returns a Task and you don't wait for the task to complete (await or .Wait()), the exception will be "unnoticed". In the best case, the runtime will write something like "Unobserved task exception" to the log. In the worst — you'll lose the error and spend a long time hunting down the source of "mystery bugs".

2. How to properly catch exceptions in async code?

Use await + try-catch

Consider the correct approach:

try
{
    await SendReportAsync(); // Wait for the Task to finish
    Console.WriteLine("Report sent successfully!");
}
catch (Exception ex)
{
    Console.WriteLine($"Oops! Something went wrong: {ex.Message}");
}

How does this work? When you put await before calling an async method, C# splits your method into two parts: before and after the await. If an exception occurs in the async part, it will "bubble up" at the point where you used await, and you can catch it with the classic try-catch.

Example for the app

Add error handling for sending the report in our demo:

public async Task StartReportProcessAsync()
{
    try
    {
        await SendReportAsync();
        Console.WriteLine("Report sent successfully!");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error while sending the report: {ex.Message}");
    }
}

And call it:

await StartReportProcessAsync();

.Wait(), .Result — not the best, but workable tactic for console apps

Sometimes, especially in console apps, you can't use await at the top level (older C# versions, the Main method). Then you have to synchronously wait for the task with .Wait() or .Result.

try
{
    SendReportAsync().Wait();
}
catch (AggregateException aggEx)
{
    foreach (var ex in aggEx.InnerExceptions)
        Console.WriteLine($"Error: {ex.Message}");
}

Why is that? Calls to .Wait() and .Result always wrap the original exception in an AggregateException. This is a container that can hold one or more exceptions. It may contain one (or several!) inner exceptions, so you have to unpack them in a loop. Read more about AggregateException in the official documentation.

Important!

In modern .NET versions (starting with C# 7.1) you can declare an async Main and use await right in the entry point:

static async Task Main(string[] args)
{
    await StartReportProcessAsync();
}

3. Exceptions in "fire-and-forget" tasks

What happens if you start an async method without waiting for it and without keeping a reference to the task?

SendReportAsync(); // "Forgot" about the task

In this situation there's a problem: an exception that occurs in the task won't be handled by anyone. Sometimes (depending on the environment and settings) the app can even crash. Other times it will just log a warning. This isn't a C# bug, but a consequence of how tasks work.

How to do it right?

  • Ideally: never use "fire-and-forget" unless you're sure the task can't fail catastrophically.
  • If an async method truly must run "fire-and-forget", do explicit error handling inside the method.
public async Task SendReportSafeAsync()
{
    try
    {
        await Task.Delay(100);
        throw new InvalidOperationException("Error while sending!");
    }
    catch (Exception ex)
    {
        // Log or handle the error
        Console.WriteLine($"[Log] Exception: {ex.Message}");
    }
}

// Call
SendReportSafeAsync();

General recommendation: If a task is unobserved and you don't use await, make sure to wrap the body of the async method in try-catch. That way you won't lose the error and can at least log it.

4. Exceptions and parallel tasks: Task.WhenAll and friends

Often in real apps you need to start multiple independent async tasks and wait for them all to finish. For example, when you send reports to several recipients in parallel:

var tasks = new List<Task>
{
    SendReportAsync(),
    SendReportAsync(),
    SendReportAsync()
};

await Task.WhenAll(tasks);

What happens if one (or several) tasks throw an exception?

How to catch such errors?

When you use await with Task.WhenAll(tasks) — if at least one task finished with an error, await will rethrow the exception from the first task that failed (it won't be wrapped in AggregateException).
But here's the nuance: if multiple tasks failed, then an AggregateException with a set of inner exceptions may be thrown.

try
{
    await Task.WhenAll(tasks);
}
catch (Exception ex)
{
    // If this is AggregateException — unpack it
    if (ex is AggregateException agg)
    {
        foreach (var inner in agg.InnerExceptions)
            Console.WriteLine($"Error in task: {inner.Message}");
    }
    else
    {
        Console.WriteLine($"Error: {ex.Message}");
    }
}

For await with a single task, the exception is typically not wrapped in AggregateException. But with WhenAll — it can be!

5. Async delegates and error handling

In UI apps (WPF, WinForms, ASP.NET) event handlers are often written as async lambdas. If an exception in such a handler "escapes", the outcome depends on the UI framework: the app might crash or swallow the error.

Recommendation

Always use try-catch inside async delegates:

button.Click += async (sender, args) =>
{
    try
    {
        await SendReportAsync();
    }
    catch (Exception ex)
    {
        MessageBox.Show($"Error: {ex.Message}");
    }
};
2
Task
C# SELF, level 59, lesson 4
Locked
Asynchronous Exception Handling in a Handler
Asynchronous Exception Handling in a Handler
1
Survey/quiz
Asynchronous Programming, level 59, lesson 4
Unavailable
Asynchronous Programming
Async vs. Multithreading
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION