High-Performance .NET: Async, Multithreading, and Parallel Programming Tasks in .Net Created: 15 Aug 2026 Updated: 28 Aug 2026

Handling Exceptions in Async Code

In synchronous code, an exception travels immediately up the call stack until something catches it. Asynchronous code behaves differently: an exception thrown inside an async method is captured and stored inside the faulted task that the method returns, and it is only re-thrown at the moment that task is awaited. This single fact explains almost every surprise beginners run into with asynchronous error handling. A normal try-catch block around an awaited call works exactly as expected, because await is the moment the stored exception is unpacked and thrown again. But a call that is started and never awaited never reaches that moment, so its exception simply sits inside the task, unobserved, unless something else goes out of its way to inspect it. The six parts below walk through the ordinary case and five situations that trip people up: a missing await, a void-returning event handler that cannot be awaited by its caller, a fire-and-forget call, Task.WhenAll combining several failures into one AggregateException, and Task.WhenAny only exposing the winning task's outcome through Unwrap().

Every part reuses the same small helper, SubmitScoreAsync, which stands in for any asynchronous operation that can fail: it simulates sending a quiz score to a server and throws an ArgumentException when the score falls outside the valid 0-100 range. Reusing one helper keeps the six parts easy to compare, because the only thing that changes between them is how the failure is (or is not) observed, not what is failing. Here is the full core concept example exactly as it appears in the class file, together with the helper method it calls:

/// <summary>
/// Core concept example.
/// SubmitScoreAsync stands in for any asynchronous operation that can fail: it accepts
/// a quiz score and throws ArgumentException when the score is outside the valid
/// 0-100 range. Every part below reuses it to show a different aspect of exception
/// handling in asynchronous code.
/// </summary>
public static async Task Run()
{
Console.WriteLine("=== Exception handling in async code: core concept ===");
Console.WriteLine();

// ----------------------------------------------------------------------------
// Part 1: catching an exception when the task IS awaited.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 1: catching an exception through await ---");

try
{
// -5 is deliberately invalid, so the exception is guaranteed to happen.
await SubmitScoreAsync(-5);
}
catch (ArgumentException ex)
{
// This runs because await re-throws the exception that is stored inside
// the faulted task, right at the point where the task is awaited.
Console.WriteLine($"[caught] {ex.Message}");
}
Console.WriteLine();

// ----------------------------------------------------------------------------
// Part 2: a missing await lets the exception slip past the catch block.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 2: a missing await slips past the catch block ---");

Task missedTask = null!;
try
{
// No await here on purpose. The method starts running in the background,
// but the try block moves on immediately instead of waiting for it.
missedTask = SubmitScoreAsync(-5);
Console.WriteLine("[try] Reached this line without waiting for SubmitScoreAsync to finish.");
}
catch (ArgumentException)
{
Console.WriteLine("[catch] This line never runs, because nothing was awaited.");
}

// Giving the background call time to actually fail before inspecting it below.
await Task.Delay(100);
Console.WriteLine($"[after the fact] missedTask.Status={missedTask.Status}, IsFaulted={missedTask.IsFaulted}");
Console.WriteLine();

// ----------------------------------------------------------------------------
// Part 3: an async void handler must catch its own exceptions.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 3: an async void handler protects itself ---");

// A local function with a void return type stands for a UI event handler, for
// example a button Click handler. Its caller has no Task to await and
// therefore cannot wrap the call in its own try-catch, so the handler must
// catch its own exceptions instead.
async void HandleScoreSubmitted(int score)
{
try
{
await SubmitScoreAsync(score);
Console.WriteLine("[handler] Score accepted.");
}
catch (ArgumentException ex)
{
Console.WriteLine($"[handler] Handled internally: {ex.Message}");
}
}

// This call looks like an ordinary method call, exactly like the runtime
// raising a UI event: there is nothing here to await.
HandleScoreSubmitted(-5);

// A short delay lets the handler above finish printing before Part 4 starts;
// this is only to keep the console output in a readable order.
await Task.Delay(100);
Console.WriteLine();

// ----------------------------------------------------------------------------
// Part 4: observing a fire-and-forget task with ContinueWith.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 4: observing a fire-and-forget task ---");

// SubmitScoreAsync(-5) is started here but not awaited directly. ContinueWith
// attaches a follow-up that always runs, so the failure is still observed
// instead of being lost. The discard "_ =" documents that the continuation
// task is intentionally never awaited, which is the whole point of a
// fire-and-forget call.
_ = SubmitScoreAsync(-5).ContinueWith(finishedTask =>
{
if (finishedTask.IsFaulted)
{
// Flatten() is not strictly needed for a single exception, but it is a
// safe habit, because Exception is always an AggregateException here.
Console.WriteLine($"[fire-and-forget] Observed failure: {finishedTask.Exception?.Flatten().Message}");
}
});

// Waiting here only gives the continuation above time to print before the
// demo moves on; a real fire-and-forget call would not normally do this.
await Task.Delay(150);
Console.WriteLine();

// ----------------------------------------------------------------------------
// Part 5: Task.WhenAll and AggregateException.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 5: Task.WhenAll and AggregateException ---");

Task allSubmissions = null!;
try
{
// Two invalid scores and one valid one, all started at the same time. The
// delays are deliberately different so the two failures always happen in
// the same order and the printed list is reproducible between runs.
// Awaiting Task.WhenAll unwraps the AggregateException and rethrows only
// the FIRST failure, which is why allSubmissions is kept outside the try
// block: it is the only way to reach every failure afterwards.
allSubmissions = Task.WhenAll(
SubmitScoreAsync(-5, delayMilliseconds: 50),
SubmitScoreAsync(150, delayMilliseconds: 150),
SubmitScoreAsync(80, delayMilliseconds: 250));

await allSubmissions;
}
catch (ArgumentException)
{
IReadOnlyCollection<Exception> allErrors = allSubmissions.Exception!.Flatten().InnerExceptions;
Console.WriteLine($"[WhenAll] {allErrors.Count} of 3 submissions failed:");
foreach (Exception error in allErrors)
{
Console.WriteLine($" - {error.Message}");
}
}
Console.WriteLine();

// ----------------------------------------------------------------------------
// Part 6: Task.WhenAny, Unwrap and the remaining tasks.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 6: Task.WhenAny, Unwrap and the remaining tasks ---");

// Delays are chosen on purpose so the finishing order is always the same:
// the -5 submission fails first, then the 150 submission fails, then the
// 80 submission succeeds, and the plain delay finishes last of all.
List<Task> submissions =
[
SubmitScoreAsync(-5, delayMilliseconds: 50),
SubmitScoreAsync(150, delayMilliseconds: 200),
SubmitScoreAsync(80, delayMilliseconds: 300),
Task.Delay(400)
];

Task<Task> firstFinished = Task.WhenAny(submissions);

try
{
// Task.WhenAny only completes when ONE task finishes; by itself it does
// NOT rethrow that task's exception. Unwrap() reaches into the wrapper
// task so the real outcome - success or failure - is what gets awaited.
await firstFinished.Unwrap();
Console.WriteLine("[WhenAny] The fastest submission finished without an error.");
}
catch (ArgumentException ex)
{
Console.WriteLine($"[WhenAny] The fastest submission failed: {ex.Message}");
}

// The remaining submissions are still running (or already finished) at this
// point and must be observed separately, otherwise their failures could go
// unnoticed.
List<Task> remainingSubmissions = submissions.Where(task => task != firstFinished.Result).ToList();

await Task.WhenAll(remainingSubmissions).ContinueWith(_ =>
{
foreach (Task remaining in remainingSubmissions)
{
if (remaining.IsFaulted)
{
Console.WriteLine($"[WhenAny] A remaining submission also failed: {remaining.Exception?.Flatten().Message}");
}
}
});
Console.WriteLine();
}

/// <summary>
/// Simulates sending a quiz score to a server. Throws ArgumentException when the score
/// is outside the valid range. The optional delay lets callers control the finishing
/// order when several calls are started at the same time (see Part 6 of Run()).
/// </summary>
private static async Task SubmitScoreAsync(int score, int delayMilliseconds = 50)
{
await Task.Delay(delayMilliseconds);

if (score is < 0 or > 100)
{
throw new ArgumentException($"Score {score} is outside the valid 0-100 range.");
}
}

Walking through the parts in order: Part 1 is the baseline everyone expects— SubmitScoreAsync(-5) is awaited directly inside the try block, so when it faults, await unpacks the stored ArgumentException and throws it right there, and the catch block runs normally. Part 2 makes exactly one change, removing the await, and that single change is enough to break the pattern completely: the try block moves straight past the call, the catch block never executes even though the exact same exception is thrown a little later in the background, and the only way to prove that anything went wrong at all is to keep a reference to the task and inspect its Status and IsFaulted properties afterwards—here they read Faulted and true, confirming the exception really happened, it was just never observed by the try-catch. Part 3 shows the situation that makes this dangerous in real applications: a method with a void return type, the shape every UI event handler is forced to use, gives its caller nothing to await, so a try-catch around the call site is not even possible; the only safe fix is to move the try-catch inside the handler itself, exactly as HandleScoreSubmitted does. Part 4 shows the standard way to keep an eye on a call that is genuinely meant to run in the background: ContinueWith attaches a callback that always runs once the original task finishes, checks IsFaulted, and logs the error, so nothing is lost even though nothing is awaited at the call site. Part 5 demonstrates a subtlety of Task.WhenAll: when several of the combined tasks fail, awaiting the combined task only re-throws the first exception it finds, which would normally hide the second failure completely; the fix is to keep the Task.WhenAll task itself in a variable declared outside the try block, so that after the catch fires, its Exception property can be read and flattened to list every failure, not just one. Part 6 covers the trickiest case: Task.WhenAny resolves as soon as the fastest task in the list finishes, but the task it returns is a wrapper around that winner, and awaiting the wrapper on its own does not surface the winner's exception at all—Unwrap() has to be used to reach the real, inner task so its actual outcome can be awaited and caught. Even then, the other tasks in the list are still running after WhenAny returns, so the code filters them out of the original list and awaits them too, reporting any of them that turn out to have failed.

Running Run() produces the following output. Every delay in the example is a fixed number of milliseconds chosen specifically to keep the finishing order the same on every run, so this output is fully reproducible:

=== Exception handling in async code: core concept ===

--- Part 1: catching an exception through await ---
[caught] Score -5 is outside the valid 0-100 range.

--- Part 2: a missing await slips past the catch block ---
[try] Reached this line without waiting for SubmitScoreAsync to finish.
[after the fact] missedTask.Status=Faulted, IsFaulted=True

--- Part 3: an async void handler protects itself ---
[handler] Handled internally: Score -5 is outside the valid 0-100 range.

--- Part 4: observing a fire-and-forget task ---
[fire-and-forget] Observed failure: One or more errors occurred. (Score -5 is outside the valid 0-100 range.)

--- Part 5: Task.WhenAll and AggregateException ---
[WhenAll] 2 of 3 submissions failed:
- Score -5 is outside the valid 0-100 range.
- Score 150 is outside the valid 0-100 range.

--- Part 6: Task.WhenAny, Unwrap and the remaining tasks ---
[WhenAny] The fastest submission failed: Score -5 is outside the valid 0-100 range.
[WhenAny] A remaining submission also failed: One or more errors occurred. (Score 150 is outside the valid 0-100 range.)

Notice that the message logged in Part 4 and the second message in Part 6 both read "One or more errors occurred. (...)" rather than the plain score message from Part 1. That wording comes from AggregateException.Message itself, because Flatten() still returns an AggregateException, not the original exception directly; Part 5 instead walks the flattened InnerExceptions collection, which is why its two lines show the clean, original messages. Both are correct techniques, useful in slightly different situations: a quick flattened message for a log line, or the full InnerExceptions list when every individual failure needs to be reported separately, exactly as the dashboard example below does.

The real-world example applies the Task.WhenAll lesson from Part 5 to something almost every application does: loading several independent pieces of data in parallel and needing to know about every one that failed, not just whichever one happened to be reported first. A small dashboard loads three widgets at the same time; the "Inventory" widget is deliberately made to fail so the error-reporting path is visible. Here is the code exactly as it appears in the class file:

/// <summary>
/// Real-world example.
/// A dashboard loads three independent widgets in parallel with Task.WhenAll. One of
/// them fails; the code reports every widget that failed instead of stopping at the
/// first error, which is the everyday reason to read Exception instead of only relying
/// on the exception that await itself rethrows.
/// </summary>
public static async Task RunDashboardDataLoadScenario()
{
Console.WriteLine("=== Real-world scenario: loading a dashboard from three services ===");

Task loadAllWidgets = null!;
try
{
// All three widgets are requested at the same time. The "Inventory" widget
// is deliberately made to fail so the error-reporting path is visible.
Task salesWidget = LoadWidgetAsync("Sales", shouldFail: false);
Task inventoryWidget = LoadWidgetAsync("Inventory", shouldFail: true);
Task trafficWidget = LoadWidgetAsync("Traffic", shouldFail: false);

loadAllWidgets = Task.WhenAll(salesWidget, inventoryWidget, trafficWidget);
await loadAllWidgets;

Console.WriteLine("Dashboard loaded successfully.");
}
catch (InvalidOperationException)
{
// Reading Exception here, instead of relying only on the single exception
// that await rethrew, makes sure every failed widget is reported.
foreach (Exception error in loadAllWidgets.Exception!.Flatten().InnerExceptions)
{
Console.WriteLine($"Widget failed to load: {error.Message}");
}
}

Console.WriteLine("Dashboard render finished.");
Console.WriteLine();
}

/// <summary>
/// Simulates loading one dashboard widget from a remote service. Throws
/// InvalidOperationException when the simulated service is unavailable.
/// </summary>
private static async Task LoadWidgetAsync(string widgetName, bool shouldFail)
{
// 100 ms stands for a network call to the widget's data service.
await Task.Delay(100);

if (shouldFail)
{
throw new InvalidOperationException($"The '{widgetName}' service is unavailable.");
}
}

The three widgets are started together and awaited through a single Task.WhenAll call, exactly like Part 5 of the core example. Because loadAllWidgets is declared before the try block, it is still reachable inside the catch block once Task.WhenAll has faulted, and its Exception property, flattened, lists every widget that actually failed—in this deterministic run, only "Inventory". If a second widget were also made to fail, the same foreach loop would print a second line for it automatically, without any change to the catch block itself. Running this method produces:

=== Real-world scenario: loading a dashboard from three services ===
Widget failed to load: The 'Inventory' service is unavailable.
Dashboard render finished.

A few rules of thumb summarize all six parts: always await an asynchronous call that you place inside a try-catch block, since an unawaited call gives the catch block nothing to observe. Treat any void-returning async method, most commonly a UI event handler, as fully responsible for catching its own exceptions, because nothing above it in the call stack ever gets the chance. When a call is genuinely meant to run unobserved, attach a ContinueWith (or, in modern code, wrap it with a small helper that logs failures) so its errors are still logged instead of silently disappearing. When combining several tasks with Task.WhenAll, remember that a plain await only surfaces the first failure—keep the combined task in a variable so its Exception property can be flattened for the complete picture. And when racing tasks with Task.WhenAny, always call Unwrap() before awaiting the result, and always continue observing the tasks that did not win the race, or their failures will quietly go unnoticed.

Both methods are called one after another from Program.cs, awaited because both are async Task methods:

await AsyncExceptionHandlingExample.Run();
await AsyncExceptionHandlingExample.RunDashboardDataLoadScenario();


Share this lesson: