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}");
}
};
GO TO FULL VERSION