Exceptions stored in tasks, AggregateException versus await, WhenAll with several failures, cancellation status, UnobservedTaskException and async void crashes.
An exception inside a task or async method is captured and stored in the task, which becomes Faulted. await rethrows the first stored exception with its original stack; .Wait()/.Result throw an AggregateException. await Task.WhenAll(...) also throws only the first, so inspect each task (or the WhenAll task's Exception.InnerExceptions) to see all failures. OperationCanceledException for the task's token makes it Canceled, not Faulted. Exceptions in tasks nobody observes raise TaskScheduler.UnobservedTaskException later and are otherwise ignored. Exceptions in async void go to the SynchronizationContext or the ThreadPool and crash the process.
"An exception thrown inside
Task.Runor anasync Taskmethod is captured and stored in the task, which becomesFaulted. Even an exception before the firstawaitgoes into the task, not to the caller.awaitrethrows the first stored exception with its original type and stack trace;.Wait()and.Resultthrow anAggregateException.await Task.WhenAll(...)waits for everything and then throws only the first failure, so to see all of them I keep theWhenAlltask and readException.InnerExceptions, or inspect each task. AnOperationCanceledExceptionfrom cancellation makes the taskCanceled, notFaulted, and awaiting it throwsTaskCanceledException/OperationCanceledException, which I treat as normal control flow, not an error. If nobody ever observes a faulted task, its exception surfaces later throughTaskScheduler.UnobservedTaskExceptionand is otherwise silently lost. Andasync voidhas no task at all, so its exception is rethrown on theSynchronizationContextor the thread pool and crashes the process."
| Code | Exception goes to |
|---|---|
async Task method body (before or after first await) |
The returned task (Faulted) |
Task.Run(() => throw ...) |
The returned task |
Non-async method returning Task that throws before returning |
Caller, synchronously (useful for argument validation) |
async void method |
Rethrown on the captured SynchronizationContext or the pool → usually process crash |
Thread body / ThreadPool.QueueUserWorkItem |
Unhandled → process crash |
| How | You get |
|---|---|
await t |
First exception, original type, original stack (via ExceptionDispatchInfo) |
t.Wait() / t.Result |
AggregateException wrapping all |
t.GetAwaiter().GetResult() |
First exception, original type (blocks) |
t.Exception |
AggregateException with all inner exceptions (null if not faulted) |
t.Status, IsFaulted, IsCanceled |
State only |
WhenAll and WhenAny| Combinator | Failure behaviour |
|---|---|
await Task.WhenAll(a, b, c) |
Waits for all; throws the first failure; all failures are in the WhenAll task's Exception.InnerExceptions |
Task.WhenAll where some are canceled and none faulted |
Result task is Canceled |
await Task.WhenAny(...) |
Never throws for the inner task: it returns the completed task, which you must then await to observe its exception |
Task.WhenEach (.NET 9+) |
Yields completed tasks; you observe each one |
Task all = Task.WhenAll(uploads);
try { await all; }
catch
{
foreach (var ex in all.Exception!.InnerExceptions) log.LogError(ex, "Upload failed");
throw;
}
| Situation | Task status |
|---|---|
async method throws OperationCanceledException |
Canceled |
Task.Run(work, token) and work throws OCE for that token |
Canceled |
Task.Run(work, token) and work throws OCE for a different token |
Faulted |
| Any other exception | Faulted |
In request code: catch (OperationCanceledException) when (ct.IsCancellationRequested) → log at Information/Debug, not Error.
async void| Case | What happens |
|---|---|
| Faulted task never awaited/inspected | When the task is finalised, TaskScheduler.UnobservedTaskException fires; since .NET 4.5 the process does not crash: the error is silently lost |
async void throws |
No task exists; exception rethrown on the context/pool → AppDomain.UnhandledException → process terminates |
Our [nightly export job] uploaded [50] files to partners with Task.WhenAll and logged the exception it caught. Ops saw one error per night and assumed one file failed. Partners reported [up to 12] missing files. In a reproduction I inspected each task and found many failures, all with the same expired SAS credential for one region; await had only thrown the first. I changed the job to keep the WhenAll task and log every inner exception with the file name, added per-file retries with backoff, rotated the credential and moved it to managed identity. We added a "failed uploads per run" metric with an alert above zero, so a partial failure can't hide behind a single log line again.
Checks debugging-level knowledge of async error handling.
Green flags
await versus .Result behaviour.WhenAll throws only the first and how to get all.Canceled from Faulted.async void consequences.Red flags
catch (Exception) and ignores cancellation semantics.// Generated MoveNext (simplified)
try { /* body */ }
catch (Exception ex)
{
state = -2;
builder.SetException(ex); // OCE -> TrySetCanceled; otherwise TrySetException (Faulted)
return;
}
SetException stores the exception in the task's ExceptionHolder wrapped in an ExceptionDispatchInfo, so when await calls GetResult(), the runtime rethrows it with the original stack plus a --- End of stack trace from previous location --- marker. Modern .NET also trims async state machine frames for readable stack traces.
await throws only oneMost code wants catch (HttpRequestException) to work after await, so await unwraps to the first inner exception. The task still holds all of them in Task.Exception, an AggregateException. You can flatten nested aggregates with ae.Flatten() and handle selectively with ae.Handle(e => ...).
public Task<Report> BuildAsync(string id, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrEmpty(id); // thrown synchronously to the caller
return BuildCoreAsync(id, ct);
async Task<Report> BuildCoreAsync(string id, CancellationToken ct)
{
var data = await _repo.LoadAsync(id, ct);
return Render(data);
}
}
TaskScheduler.UnobservedTaskException is raised.e.SetObserved() marks it handled. In .NET Framework 4.0 the default was to crash; 4.5+ changed it to ignore.async void in detailasync void OnClick(object? s, EventArgs e) // OK: event handler
{
try { await SaveAsync(); }
catch (Exception ex) { ShowError(ex); } // must handle here: nobody can catch it outside
}
AsyncVoidMethodBuilder notifies the SynchronizationContext of operation start/completion and posts exceptions to it. With no context (ASP.NET Core, console), exceptions are rethrown on the thread pool → unhandled → crash. Lambdas passed to Action parameters (e.g. list.ForEach(async x => ...), Timer callbacks) are silently async void.
| Hook | Purpose |
|---|---|
TaskScheduler.UnobservedTaskException |
Log lost task exceptions |
AppDomain.CurrentDomain.UnhandledException |
Last-chance logging before a crash |
ASP.NET Core exception middleware / IExceptionHandler (.NET 8+) |
Request-level handling |
BackgroundService exceptions (.NET 6+) |
Host logs them and, by default, stops the host (BackgroundServiceExceptionBehavior.StopHost) |
| Version | Change |
|---|---|
| .NET Framework 4.0 | Unobserved task exceptions crashed the process |
| .NET Framework 4.5 | Unobserved exceptions ignored by default; await unwraps |
| .NET Core 2.1 | Cleaner async stack traces |
| .NET 6 | BackgroundService unhandled exceptions stop the host by default |
| .NET 8 | IExceptionHandler |
| .NET 9 | Task.WhenEach |
WhenAll include one canceled and one faulted task; check the resulting status.Task.Run(() => throw new OperationCanceledException(otherToken), token) and check whether it ends Faulted or Canceled.GC.Collect(); GC.WaitForPendingFinalizers(); and watch UnobservedTaskException fire.list.ForEach(async x => { await Task.Delay(1); throw new Exception(); }); in a console app and observe the crash.BackgroundService that throws and observe the host stopping.TaskScheduler.UnobservedTaskException += (_, e) =>
{
Console.WriteLine($"Unobserved: {e.Exception.InnerException?.Message}");
e.SetObserved();
};
Task[] uploads =
[
UploadAsync("a.csv", fail: false),
UploadAsync("b.csv", fail: true),
UploadAsync("c.csv", fail: true),
];
Task all = Task.WhenAll(uploads);
try
{
await all;
}
catch (Exception first)
{
Console.WriteLine($"await threw only: {first.Message}");
foreach (var ex in all.Exception!.InnerExceptions)
Console.WriteLine($" failure: {ex.Message}");
}
// Cancellation gives Canceled, not Faulted.
using var cts = new CancellationTokenSource();
cts.Cancel();
Task cancelled = Task.Delay(1000, cts.Token);
try { await cancelled; } catch (OperationCanceledException) { }
Console.WriteLine($"Status: {cancelled.Status}");
static async Task UploadAsync(string file, bool fail)
{
await Task.Delay(20);
if (fail) throw new IOException($"{file} upload failed");
}
await Task.WhenAllWhat happens: one error is logged when several tasks failed.
Why it is wrong: await rethrows only the first.
Do instead: keep the WhenAll task and log Exception.InnerExceptions, or inspect each task.
What happens: _ = DoWorkAsync(); with no handling.
Why it is wrong: failures are silently lost.
Do instead: await them, or attach an explicit fault-logging continuation, or use a background queue.
async void for non-event methodsWhat happens: public async void SendAsync().
Why it is wrong: the caller can't await or catch; exceptions crash the process.
Do instead: return Task.
async void lambdasWhat happens: items.ForEach(async i => await SaveAsync(i)); or async lambdas passed to Action parameters.
Why it is wrong: compiles to async void; no waiting, crashes on error.
Do instead: foreach with await, or Parallel.ForEachAsync.
What happens: catch (Exception ex) { log.LogError(ex, ...) } catches client disconnects.
Why it is wrong: floods logs and alerts with expected events.
Do instead: catch (OperationCanceledException) when (ct.IsCancellationRequested) at a lower level.
.ResultWhat happens: catch (IOException) around task.Result.
Why it is wrong: .Result throws AggregateException.
Do instead: await, or unwrap InnerExceptions.
WhenAny to throwWhat happens: await Task.WhenAny(tasks) is assumed to surface failures.
Why it is wrong: it returns the completed task without throwing.
Do instead: await the returned task.
UnobservedTaskException for alertingWhat happens: production alerts depend on this event. Why it is wrong: it fires only on finalisation, unpredictably, or never if the task stays referenced. Do instead: observe tasks explicitly; use the event only as a safety-net log.
catch without rethrowingWhat happens: catch (Exception) { } inside an async method.
Why it is wrong: the task completes successfully; callers believe the work succeeded.
Do instead: handle specifically or rethrow with throw;.
What happens: argument checks in an async method surface only when awaited (possibly never).
Why it is wrong: bugs appear far from the call site or are lost.
Do instead: validate in a non-async wrapper and delegate to an async local function.
I design so every task has an owner who observes it. Exceptions that vanish are worse than exceptions that crash, because you only find them through missing data.
| Pattern | Exception behaviour | My rule |
|---|---|---|
await task |
First exception | Default |
await Task.WhenAll |
First exception, rest in task | Log all inner exceptions |
| Fire-and-forget | Unobserved, silent | Avoid or wrap with logging |
async void |
Crashes the process | Event handlers only, with try/catch |
| Cancellation | Canceled status |
Not an error |
An exception filter is a when (condition) clause on a catch: catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound). The catch is only chosen if the condition is true; otherwise the runtime keeps searching up the stack as if that catch did not exist. Filters run during the runtime's first pass, before the stack is unwound, so when no handler matches, a crash dump still shows the original frames and local state. That is better than catch-check-rethrow. Good uses: catching by status code or error number, logging without catching (when (Log(ex)) returning false), and excluding cancellation. Keep filters cheap and side-effect free (except deliberate logging), and remember an exception thrown inside a filter is swallowed and treated as false.
Fire-and-forget means starting a task and not awaiting it, such as _ = SendEmailAsync(). The risks are: exceptions are never observed, scoped services like DbContext are disposed when the request ends (giving ObjectDisposedException), work is lost on shutdown or crash, there is no limit on how much work piles up, and HttpContext may be used after the request is gone. In ASP.NET Core, hand the work to a queue (Channel<T>) processed by a BackgroundService that creates its own scope, logs failures and drains on shutdown. If the work must not be lost, use a durable queue or an outbox table.
async tells the compiler to rewrite the method into a state machine and lets you use await inside it; it does not create a thread. At await x, the code calls x.GetAwaiter() and checks IsCompleted. If it is already complete, execution continues synchronously. If not, the method registers the rest of itself as a continuation, captures the current SynchronizationContext (or non-default TaskScheduler), and returns an incomplete task to its caller, releasing the thread. When the awaited work finishes, the continuation resumes, on the captured context if there was one, otherwise on the ThreadPool. Exceptions and results flow through the returned task.
How to tell it (STAR)
Partners said files were missing, but our logs showed only one error per night. I realised `await Task.WhenAll` throws just the first exception. After logging all inner exceptions, we saw [a dozen] failures from an expired credential. We fixed the credential, added retries and an alert on failure count.
Last updated 31 Oct 2026.