High-Performance .NET: Async, Multithreading, and Parallel Programming Concurrency Created: 27 Aug 2026 Updated: 27 Aug 2026

Exceptions and async/await

Exceptions are built on top of the call stack. The call stack is the data structure the system uses to implement the idea of a method call at all: calling a method pushes a return address onto it, returning from a method jumps back to the address on top of it. When an exception is thrown, the runtime walks that same stack downwards, frame by frame, looking for a catch clause that matches. If it finds one, execution continues there; if it reaches the bottom of the stack without finding one, the program crashes. The important part of that description is that the search happens at run time and follows the call stack, not the shape of the source code. Two pieces of code that sit next to each other in a text file do not necessarily run on the same stack, and when they do not, a try block wrapped around one of them does not protect the other.

That is exactly the situation asynchronous code creates. Wrapping a try block around the line that subscribes a lambda to an event protects only the subscription; the lambda itself runs much later, called by whatever raises the event, on a completely different stack, so an exception thrown inside it bubbles up into the event-raising code and never comes anywhere near that catch clause. An await has the same underlying shape, because await is essentially a continuation: everything after the await is packaged into a callback that the awaited operation invokes when it finishes, frequently on a different thread and always on a call stack that no longer contains the method that started it all. Written by hand with ContinueWith, the code after an await lives inside a lambda, and a try block in the enclosing method would no longer cover it.

The C# compiler goes to a lot of trouble to hide this, and the hiding is so effective that most developers never think about it. Inside a single method, the compiler simply moves the try/catch into the generated state machine so that a try block spanning an await really does catch what the code after the await throws. Across method boundaries it cannot do that, because at compile time it has no idea where the caller's catch clause is, so it does something else instead: it wraps the entire body of every async method in generated exception handling, catches whatever escapes, and stores the exception in the Task the method returns. The Task moves to the Faulted state and keeps the exception in its Exception property. On the other side, await checks the Task it is given, and if that Task is faulted it takes the exception back out and rethrows it at the point of the await. The result is that a failing asynchronous call looks and behaves exactly like a failing synchronous one, even though the exception was caught in one place, carried across threads inside an object, and rethrown somewhere else entirely.

The first consequence worth knowing is that an asynchronous method has two different ways to fail. A method that returns a Task but is not marked async - the very common shape of a wrapper that validates its arguments and then delegates to the real worker - gets no generated exception handling at all, so anything it throws is thrown the ordinary way, right at the call site, before a Task ever exists. A method that is marked async never behaves that way: from the caller's point of view it does not throw, it hands back a broken Task. It is worth being precise here, because this is where a widespread half-truth lives. It is often said that an exception thrown before the first await propagates synchronously while one thrown after it is stored in the Task. In C# that is not what happens: the async keyword covers the whole method body, so a guard clause that throws on the first line still produces a Task that is already Faulted by the time the caller receives it. The only real distinction is async or not async, and the practical effect of it is that code which inspects Tasks without awaiting them - collecting them for Task.WhenAll or Task.WhenAny, for instance - has to be ready for both a thrown exception and a faulted Task, whereas code that simply awaits sees only one uniform behaviour.

The second consequence is the strange type of the exception stored in a Task. It is always an AggregateException, a container class whose entire purpose is to hold several other exceptions at once. A Task does not necessarily represent one operation; the Task returned by Task.WhenAll represents many of them running at the same time, and more than one of them can fail, so a single exception slot would not be enough. In practice that container is almost always carrying exactly one exception, and await refuses to hand it to you: when the awaited Task is faulted, await unwraps the AggregateException and rethrows only the first exception inside it. Every other failure in that container, and everything those exceptions know about what went wrong, is silently dropped. Which one counts as first is an implementation detail of how the failures were collected, so it should never be relied upon. If a Task genuinely represents several operations and several of them may fail, await is simply the wrong tool for reading the result, and the Task.Exception property has to be read directly.

The third consequence is the most damaging one in practice, and it follows from the fact that the whole mechanism depends on somebody looking at the returned Task. If an asynchronous call is made and its Task is thrown away - almost always because an await was forgotten - then the generated code still catches the exception and still stores it in that Task, and nothing else ever happens. Nothing is printed, nothing is logged, no debugger breaks, and the program carries on as if the work had succeeded. If the failing method lives in a library and the debugger's "just my code" setting is on, the exception is not even visible while stepping through. This is the single most useful debugging heuristic in this whole chapter: when part of a program simply stops working and there is no exception anywhere to explain it, look for a missing await. The compiler does try to help, by the way; calling an asynchronous method without awaiting it from inside an async method produces warning CS4014, and treating that particular warning as an error is one of the cheapest bug-prevention settings a project can turn on.

The last consequence concerns async void methods, which exist mainly so that asynchronous code can be used in event handlers, where the signature is fixed and returning a Task is not an option. An async void method returns nothing, so there is no Task to put a failure into, and the compiler therefore does not generate the catching code described above. An exception that escapes such a method is instead thrown on the SynchronizationContext that was running the continuation, which in most applications means an unhandled exception on a thread that has no idea what to do with it, which in turn means the process dies. There is no catch clause anywhere in the calling code that can stop this, because the calling code is long gone by then. The rule that follows is absolute: never let an exception escape an async void method. Its entire body belongs inside a try/catch that handles or at least logs everything.

The core example below turns all four of those points into code that can be run and watched. Its first part calls three deliberately failing methods and prints where each failure surfaces; its second part combines two failing operations into one Task and compares what await reports with what the Task actually holds; its third part reproduces the forgotten await; and its fourth part shows an async void method looking after itself. Here is the complete core concept example exactly as it appears in the class file:

/// <summary>
/// Core concept example.
/// </summary>
public static async Task Run()
{
Console.WriteLine("=== Exceptions and async/await: core concept ===");
Console.WriteLine();

Console.WriteLine("--- Part 1: thrown at the call site, or stored in the Task ---");

// Not async: the guard clause throws here, before any Task exists.
try
{
Task<string> neverCreated = LoadReportOrThrow("");
Console.WriteLine("[not async] this line is never reached");
}
catch (ArgumentException ex)
{
Console.WriteLine($"[not async] caught at the call site without any await: {ex.GetType().Name}");
}

// Async: the same guard clause faults the Task instead of throwing.
Task<string> faultedImmediately = LoadReportAsync("");
Console.WriteLine($"[async] nothing was thrown; the returned Task is: {faultedImmediately.Status}");

// Async failing after its first await: the Task faults only later.
Task<string> faultedLater = LoadReportAsync("q3-sales");
Console.WriteLine($"[async] right after the call the Task is: {faultedLater.Status}");

try
{
string report = await faultedLater;
Console.WriteLine($"[async] loaded {report}");
}
catch (FileNotFoundException ex)
{
// await pulls the exception back out of the Task and rethrows it here.
Console.WriteLine($"[async] await rethrew it here: {ex.GetType().Name}");
}

Console.WriteLine($"[async] the Task is now: {faultedLater.Status}");
Console.WriteLine($"[async] Task.Exception holds a: {faultedLater.Exception!.GetType().Name}");
Console.WriteLine($"[async] which wraps the real one: {faultedLater.Exception.InnerException!.GetType().Name}");
Console.WriteLine();

Console.WriteLine("--- Part 2: one Task, two failures, one rethrown exception ---");

// Two failing operations, started together.
Task<string> reportLoad = LoadReportAsync("q4-sales");
Task archiveCleanup = ArchiveOldReportsAsync();

// One Task standing for both operations, so it may have to carry two exceptions.
Task bothOperations = Task.WhenAll(reportLoad, archiveCleanup);

try
{
await bothOperations;
}
catch (Exception ex)
{
// Only the first exception in the AggregateException reaches this catch clause.
Console.WriteLine($"await rethrew exactly one exception: {ex.GetType().Name}");
}

// Reading Task.Exception is the only way to see all of the failures.
AggregateException allFailures = bothOperations.Exception!;
Console.WriteLine($"Task.Exception really holds {allFailures.InnerExceptions.Count} exceptions:");
foreach (Exception failure in allFailures.InnerExceptions)
{
Console.WriteLine($" - {failure.GetType().Name}: {failure.Message}");
}

Console.WriteLine();

Console.WriteLine("--- Part 3: the lost exception ---");

// The missing await is the bug; CS4014 is the compiler warning about it.
#pragma warning disable CS4014
LoadReportAsync("q1-sales");
#pragma warning restore CS4014

// Long enough for the call above to have failed.
await Task.Delay(200);
Console.WriteLine("The call above already failed, and nothing at all was reported.");

// The same call, this time kept in a variable, so the failure can still be found.
Task<string> keptTask = LoadReportAsync("q1-sales");
await Task.Delay(200);
Console.WriteLine($"The same call kept in a variable: {keptTask.Status} / {keptTask.Exception!.InnerException!.GetType().Name}");
Console.WriteLine();

Console.WriteLine("--- Part 4: async void has to handle everything itself ---");

// Nothing to await here, and nothing to store a failure in.
SendReminderEmail("weekly-summary");

// Only to keep the console output in a predictable order.
await Task.Delay(300);
Console.WriteLine();
}

The methods it calls are deliberately small, and the difference between the first two of them is the whole lesson of Part 1:

/// <summary>
/// Returns a Task but is not marked async, so its guard clause throws at the call site.
/// </summary>
private static Task<string> LoadReportOrThrow(string reportName)
{
if (string.IsNullOrWhiteSpace(reportName))
{
throw new ArgumentException("A report name is required.", nameof(reportName));
}

return LoadReportAsync(reportName);
}

/// <summary>
/// An async method that can fail before its first await and after it.
/// </summary>
private static async Task<string> LoadReportAsync(string reportName)
{
if (string.IsNullOrWhiteSpace(reportName))
{
throw new ArgumentException("A report name is required.", nameof(reportName));
}

// Stands in for a short piece of real I/O.
await Task.Delay(50);

throw new FileNotFoundException($"The report '{reportName}' does not exist.");
}

/// <summary>
/// A second failing operation, used to give Task.WhenAll something to fail alongside.
/// </summary>
private static async Task ArchiveOldReportsAsync()
{
await Task.Delay(30);

throw new TimeoutException("The archive service did not answer in time.");
}

/// <summary>
/// An async void method, the shape used by event handlers.
/// </summary>
private static async void SendReminderEmail(string reportName)
{
// The whole body sits inside try/catch, because nothing can catch it from outside.
try
{
await Task.Delay(50);

throw new InvalidOperationException($"The mail server rejected the reminder for '{reportName}'.");
}
catch (Exception ex)
{
Console.WriteLine($"[async void] handled inside the method itself: {ex.GetType().Name}");
}
}

All four helpers fail on purpose, and they take the arguments they take only to make that failure easy to trigger and easy to read. The reportName parameter of LoadReportOrThrow and LoadReportAsync is the name of the report being loaded: passing an empty string trips the guard clause, and passing a real-looking name such as q3-sales gets past the guard and into the body, where the method fails a little later instead. The two methods are otherwise identical twins, and the only difference between them - the presence of the async keyword - is precisely what the example measures. The Task.Delay of fifty milliseconds inside LoadReportAsync stands in for a short piece of real input or output, such as reading a file or calling a web service; the exact number is unimportant and only has to be long enough that the method genuinely suspends, so that everything after that line really does run as a continuation rather than running straight through. ArchiveOldReportsAsync exists only to give Task.WhenAll a second operation to fail alongside the first, and its delay is deliberately shorter, thirty milliseconds against fifty, so that it is the operation that fails first in time while being passed to Task.WhenAll second. SendReminderEmail is shaped like a real event handler: it returns void rather than Task, its reportName parameter merely ends up in the error message, and its entire body sits inside a try/catch because that is the only place its failure can ever be handled.

Walking through Part 1, the first call goes to LoadReportOrThrow, which returns a Task but has no async keyword. Its guard clause throws immediately, on the caller's own stack, before it ever reaches the line that would have created a Task, so an ordinary try/catch with no await in it catches an ArgumentException. The second call goes to LoadReportAsync with the same invalid argument, and the same guard clause throws in the same position - on the first line, before any await - yet nothing is thrown at the call site at all. The call returns normally, and the returned Task is already reported as Faulted. That single line of output is the proof that in C# the async keyword, not the position of the first await, decides how a method reports failure. The third call passes a valid name, so the method reaches its await and returns there; printing the status immediately afterwards shows WaitingForActivation, because the failure has not happened yet and will only happen fifty milliseconds later, on a continuation. Awaiting that Task then rethrows the FileNotFoundException into the try/catch as if it had been thrown synchronously, and the three lines after the catch reveal the machinery underneath: the Task has become Faulted, its Exception property holds an AggregateException, and the exception the code actually threw is one level down, in InnerException.

Part 2 starts two failing operations at once and joins them with Task.WhenAll. Awaiting the combined Task rethrows exactly one exception, and the output shows that it is the TimeoutException from the archive cleanup - the failure that happened first in time, not the one belonging to the first task in the argument list. That is the practical warning contained in this part: not only does await hide all but one failure, the one it shows is not the one a reading of the source code would predict, and no logic should ever depend on which one it is. Reading Task.Exception afterwards and walking its InnerExceptions collection prints both failures with their messages, which is the only way to see the complete picture.

Part 3 makes the same call as before but without the await, with warning CS4014 suppressed for exactly one line so that the mistake can be reproduced on purpose. The wait of two hundred milliseconds that follows is comfortably longer than the fifty the call needs to fail, so by the time the next line prints, the failure has certainly happened - and the output says nothing about it whatsoever. The failure was caught by the generated code, stored in a Task nobody kept, and forgotten. The next two lines make the same call again, this time keeping the Task in a variable, and printing that variable's status and inner exception shows that the failure was never destroyed, merely unobserved. Part 4 then calls SendReminderEmail, which fails after fifty milliseconds and reports its own failure from inside its own catch clause. The wait of three hundred milliseconds afterwards is generous compared with those fifty and exists purely so that the console output stays in a readable order; in real code an async void method offers no way at all to find out when it has finished, which is another good reason to avoid it wherever the signature is free. Running the core example prints:

=== Exceptions and async/await: core concept ===

--- Part 1: thrown at the call site, or stored in the Task ---
[not async] caught at the call site without any await: ArgumentException
[async] nothing was thrown; the returned Task is: Faulted
[async] right after the call the Task is: WaitingForActivation
[async] await rethrew it here: FileNotFoundException
[async] the Task is now: Faulted
[async] Task.Exception holds a: AggregateException
[async] which wraps the real one: FileNotFoundException

--- Part 2: one Task, two failures, one rethrown exception ---
await rethrew exactly one exception: TimeoutException
Task.Exception really holds 2 exceptions:
- TimeoutException: The archive service did not answer in time.
- FileNotFoundException: The report 'q4-sales' does not exist.

--- Part 3: the lost exception ---
The call above already failed, and nothing at all was reported.
The same call kept in a variable: Faulted / FileNotFoundException

--- Part 4: async void has to handle everything itself ---
[async void] handled inside the method itself: InvalidOperationException

Put together, the core example makes four claims and then proves each of them on screen: only the method without the async keyword throws at the call site, while the async method always hands back a Task carrying the failure; a Task built from several operations stores every failure in an AggregateException while await rethrows only one of them; a call made without an await loses its failure completely even though the exception is sitting in a Task the whole time; and an async void method has to catch its own exceptions because there is no Task for the compiler to put them in.

The real-world example applies the second of those points to a situation that occurs in almost every scheduled job that does more than one thing. A nightly task uploads three report files at the same time and two of them fail. Catching the exception from the await would produce an alert mentioning a single file and would quietly lose the other one, which is the kind of bug that is only discovered when somebody notices that a report has been missing for a week. The job therefore keeps every individual Task, awaits them together for convenience, and then reads the results out of the Tasks themselves:

/// <summary>
/// Real-world example: a nightly job that has to report every failed upload.
/// </summary>
public static async Task RunReportUploadScenario()
{
Console.WriteLine("=== Real-world scenario: reporting every failed upload ===");

// Every upload is kept in its own variable, so each one can be inspected later.
Task<string> salesUpload = UploadReportAsync("sales.csv", uploadMilliseconds: 30, shouldSucceed: true);
Task<string> stockUpload = UploadReportAsync("stock.csv", uploadMilliseconds: 50, shouldSucceed: false);
Task<string> payrollUpload = UploadReportAsync("payroll.csv", uploadMilliseconds: 70, shouldSucceed: false);

Task<string[]> allUploads = Task.WhenAll(salesUpload, stockUpload, payrollUpload);

try
{
string[] receipts = await allUploads;
Console.WriteLine($"Every upload succeeded: {string.Join(", ", receipts)}");
}
catch (Exception ex)
{
// A job that stopped here would report one broken file and forget the other.
Console.WriteLine($"await reported only one failure: {ex.Message}");
}

Console.WriteLine($"The combined Task actually carries {allUploads.Exception!.InnerExceptions.Count} failures.");

// The individual tasks are what turn those failures into a report naming each file.
Console.WriteLine("Upload report:");
PrintUploadResult("sales.csv", salesUpload);
PrintUploadResult("stock.csv", stockUpload);
PrintUploadResult("payroll.csv", payrollUpload);

Console.WriteLine();
}

It relies on two small helpers, one simulating the upload and one turning a finished Task into a line of the report:

/// <summary>
/// Simulates uploading one report file.
/// </summary>
private static async Task<string> UploadReportAsync(string fileName, int uploadMilliseconds, bool shouldSucceed)
{
// Stands in for the upload itself.
await Task.Delay(uploadMilliseconds);

if (!shouldSucceed)
{
throw new IOException($"The storage service rejected '{fileName}'.");
}

return $"receipt-for-{fileName}";
}

/// <summary>
/// Prints the outcome of one finished upload by reading its Task instead of awaiting it.
/// </summary>
private static void PrintUploadResult(string fileName, Task<string> upload)
{
if (upload.Status == TaskStatus.RanToCompletion)
{
Console.WriteLine($" {fileName}: uploaded, {upload.Result}");
}
else
{
Console.WriteLine($" {fileName}: FAILED, {upload.Exception!.InnerException!.Message}");
}
}

UploadReportAsync takes three parameters, and each of them exists to keep the scenario both realistic and repeatable. fileName is the file being uploaded and shows up in the success receipt as well as in the error message, so that every line of the final report can be traced back to a file. uploadMilliseconds is how long the pretend upload takes, and the three callers pass three different values - thirty, fifty and seventy - so that the uploads always finish in the same order and the output of the scenario is identical on every run; with three equal delays the order in which the two failures were recorded could vary from run to run, and with it the failure that await happens to report. shouldSucceed decides whether the upload completes or throws, which is how the example produces one success and two failures without depending on a real network or a real storage account. PrintUploadResult takes the fileName it should print and the already finished upload Task it should inspect, and it deliberately reads that Task rather than awaiting it, because awaiting it would throw and the whole point is to report the failure calmly rather than to be interrupted by it.

The three uploads are started immediately, before anything is awaited, so they run concurrently rather than one after another, and each one is stored in its own variable. That detail is what makes the rest possible: Task.WhenAll gives back a single Task that knows a certain number of failures occurred, but it cannot say which file each failure belonged to, and the individual Tasks can. Awaiting the combined Task inside a try/catch produces exactly one message, naming stock.csv, the first upload that failed; the payroll failure is nowhere to be seen. Reading Exception.InnerExceptions.Count on the combined Task immediately afterwards proves that the information was there all along, and PrintUploadResult then converts each Task into a line of the final report. It checks Status against RanToCompletion first, because that is the only state in which a Task's Result can be read safely - reading the Result of a faulted Task rethrows the failure exactly as await would - and for the failures it reads Exception.InnerException to get past the AggregateException wrapper to the real error message. Running the scenario prints:

=== Real-world scenario: reporting every failed upload ===
await reported only one failure: The storage service rejected 'stock.csv'.
The combined Task actually carries 2 failures.
Upload report:
sales.csv: uploaded, receipt-for-sales.csv
stock.csv: FAILED, The storage service rejected 'stock.csv'.
payroll.csv: FAILED, The storage service rejected 'payroll.csv'.

The habits worth taking away from all this are short. Await every Task, and if a call is genuinely meant to be fire-and-forget, make that explicit and attach a continuation or a try/catch that logs the failure, because an ignored Task is an ignored bug. Prefer async Task over async void everywhere the signature allows it, and where it does not - which in practice means event handlers - wrap the entire body in try/catch. Reach for Task.Exception instead of await only in the specific case where one Task stands for several operations and every failure matters, as in the upload job above; for the ordinary single-operation case, await and a normal try/catch are simpler, produce better stack traces, and are what every reader of the code will expect. Put argument validation in a non-async wrapper when a caller should genuinely fail fast at the call site, and keep in mind while debugging that missing output, a silent method and no exception anywhere are the classic symptoms of a forgotten await rather than of a mysterious runtime problem. Both examples are run one after the other from the program entry point:

using ConsoleApp;

await ExceptionsAndAsyncAwaitExample.Run();
await ExceptionsAndAsyncAwaitExample.RunReportUploadScenario();


Share this lesson: