📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How are exceptions handled in tasks and async methods? What happens with Task.WhenAll, unobserved exceptions and async void?

Exceptions stored in tasks, AggregateException versus await, WhenAll with several failures, cancellation status, UnobservedTaskException and async void crashes.

Senior Debugging Advanced aggregateexception async exceptions task

🎯 Key points

  • Exceptions in tasks and async methods are stored in the task (Faulted)
  • await rethrows the first exception with its original stack
  • .Wait() and .Result throw AggregateException
  • await Task.WhenAll throws only the first; read Exception.InnerExceptions for all
  • OperationCanceledException for the task's token gives Canceled status
  • Unobserved exceptions raise UnobservedTaskException and are otherwise silent
  • async void exceptions crash the process

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"An exception thrown inside Task.Run or an async Task method is captured and stored in the task, which becomes Faulted. Even an exception before the first await goes into the task, not to the caller. await rethrows the first stored exception with its original type and stack trace; .Wait() and .Result throw an AggregateException. await Task.WhenAll(...) waits for everything and then throws only the first failure, so to see all of them I keep the WhenAll task and read Exception.InnerExceptions, or inspect each task. An OperationCanceledException from cancellation makes the task Canceled, not Faulted, and awaiting it throws TaskCanceledException/OperationCanceledException, which I treat as normal control flow, not an error. If nobody ever observes a faulted task, its exception surfaces later through TaskScheduler.UnobservedTaskException and is otherwise silently lost. And async void has no task at all, so its exception is rethrown on the SynchronizationContext or the thread pool and crashes the process."


1. Where exceptions go

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

2. Observing a faulted task

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

3. 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;
}

4. Cancellation is not failure

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.

5. Unobserved exceptions and 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

Production example

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.

🔍 What the interviewer is checking

Checks debugging-level knowledge of async error handling.

Green flags

  • Explains storage in the task and await versus .Result behaviour.
  • Knows WhenAll throws only the first and how to get all.
  • Distinguishes Canceled from Faulted.
  • Knows unobserved exceptions and async void consequences.

Red flags

  • Thinks unobserved exceptions crash modern .NET apps.
  • Wraps everything in catch (Exception) and ignores cancellation semantics.

📚 Learn in depth

1. How the builder stores exceptions

// 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.

2. Why await throws only one

Most 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 => ...).

3. Eager validation without losing async

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);
    }
}

4. The unobserved-exception mechanism

  • A faulted task that hasn't been observed keeps a finalisable "exception holder".
  • When the task becomes unreachable and the GC finalises the holder, TaskScheduler.UnobservedTaskException is raised.
  • Timing depends on GC, so it's unsuitable as primary alerting, but useful as a safety net log.
  • Calling e.SetObserved() marks it handled. In .NET Framework 4.0 the default was to crash; 4.5+ changed it to ignore.

5. async void in detail

async 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.

6. Global safety nets

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)

7. Version notes

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

8. Try it yourself

  1. Run the code example and compare the single caught exception with the full inner exception list.
  2. Make WhenAll include one canceled and one faulted task; check the resulting status.
  3. Call Task.Run(() => throw new OperationCanceledException(otherToken), token) and check whether it ends Faulted or Canceled.
  4. Create a faulted task, drop the reference, call GC.Collect(); GC.WaitForPendingFinalizers(); and watch UnobservedTaskException fire.
  5. Write list.ForEach(async x => { await Task.Delay(1); throw new Exception(); }); in a console app and observe the crash.
  6. Add a BackgroundService that throws and observe the host stopping.

💻 Code

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");
}

⚠ Common mistakes

1. Logging only the exception caught from await Task.WhenAll

What 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.

2. Starting tasks and never observing them

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.

3. async void for non-event methods

What happens: public async void SendAsync(). Why it is wrong: the caller can't await or catch; exceptions crash the process. Do instead: return Task.

4. Accidental async void lambdas

What 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.

5. Logging cancellation as an error

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.

6. Catching the original type around .Result

What happens: catch (IOException) around task.Result. Why it is wrong: .Result throws AggregateException. Do instead: await, or unwrap InnerExceptions.

7. Expecting WhenAny to throw

What 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.

8. Relying on UnobservedTaskException for alerting

What 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.

9. Swallowing exceptions in catch without rethrowing

What 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;.

10. Throwing validation errors lazily when eager is expected

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions AggregateException Why: Checks inner and aggregate exceptions
    How to answer

    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.

  2. When: Candidate mentions unobserved exceptions Why: Probes fire-and-forget risks
    How to answer

    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.

  3. When: Candidate says exceptions are stored in the task Why: Checks await mechanics
    How to answer

    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.

🛠 Real-world problems: how you handle them

Hidden upload failures behind Task.WhenAll
Situation
A [nightly export job] uploaded [50] files to partners with `Task.WhenAll`.
Signals
One error per night in logs; partners reported [up to 12] missing files.
Investigation
The code logged only the exception from `await Task.WhenAll`. Inspecting each task in a reproduction showed many failures with the same expired credential.
Resolution
Logged all inner exceptions with file names, added per-file retries, and rotated the expired credential.
Prevention
Added a metric for failed uploads per run and an alert when it is above zero.

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.

↩ Usually asked after

More in Tasks

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me