Handling Unobserved Task Exceptions in .NET
Unobserved task exceptions occur when a faulted Task in
.NET is finalized by the Garbage Collector without its exception ever
being inspected by the application. This guide explains what triggers
these exceptions, how modern .NET runtimes behave when they occur, and
the proper C# strategies—both reactive and proactive—for capturing,
logging, and preventing them.
What Causes Unobserved Task Exceptions?
When an asynchronous operation throws an exception, the returned
Task enters a Faulted state, capturing the
exception within its Exception property. If your code never
inspects this exception—such as by using await, calling
.Wait(), accessing .Result, or reading
.Exception—the exception remains unobserved.
When the Garbage Collector eventually finalizes the abandoned
Task instance, it detects that the exception was never
handled and raises the issue to the runtime.
Default Runtime Behavior
In modern .NET (.NET Core, .NET 5+ through .NET 8+), unobserved task exceptions do not terminate the process by default. Instead, the runtime suppresses the crash to maintain application stability, though the exception is still flagged internally. While this prevents unexpected termination, silent failures can hide severe bugs, corrupted states, or memory leaks.
Subscribing
to TaskScheduler.UnobservedTaskException
The primary mechanism for globally intercepting unobserved task
exceptions is the static event
TaskScheduler.UnobservedTaskException. You can attach an
event handler early in your application lifecycle (such as in
Program.cs) to log the error and mark it as observed.
using System;
using System.Threading.Tasks;
class Program
{
static void Main(string[] args)
{
TaskScheduler.UnobservedTaskException += (sender, eventArgs) =>
{
// Log the exception details
Console.WriteLine($"Unobserved exception: {eventArgs.Exception.Message}");
// Mark the exception as observed to prevent further escalation
eventArgs.SetObserved();
};
// Create an unobserved faulted task
CreateFaultedTask();
// Force GC and finalization to demonstrate the event (testing only)
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
private static void CreateFaultedTask()
{
Task.Run(() => throw new InvalidOperationException("Task failed silently."));
}
}Calling eventArgs.SetObserved() informs the runtime that
the failure has been acknowledged, preventing any secondary escalation
or strict runtime failure policies from acting on it.
Best Practices for Preventing Unobserved Tasks
While global logging is a useful safety net, modern C# applications should proactively prevent tasks from going unobserved.
1. Always Await Asynchronous Calls
The cleanest way to observe a task's state is by awaiting it within a
try/catch block. Avoid calling async methods without
await inside async contexts.
public async Task ProcessDataAsync()
{
try
{
await PerformOperationAsync();
}
catch (Exception ex)
{
// Exception is properly observed and handled
Logger.LogError(ex, "Operation failed.");
}
}2. Implement Safe Fire-and-Forget Wrappers
If an operation must run in the background without blocking the
caller, do not use raw _ = Task.Run(...) without error
handling. Use an extension method that attaches a continuation or
handles errors explicitly:
public static class TaskExtensions
{
public static void SafeFireAndForget(this Task task, Action<Exception>? onError = null)
{
task.ContinueWith(t =>
{
if (t.IsFaulted && t.Exception != null)
{
var ex = t.Exception.Flatten();
onError?.Invoke(ex);
}
}, TaskContinuationOptions.OnlyOnFaulted);
}
}Usage:
Task.Run(() => DoBackgroundWork()).SafeFireAndForget(ex =>
{
Logger.LogError(ex, "Background task encountered an error.");
});3. Avoid async void
Except for UI event handlers, avoid using async void. An
unhandled exception in an async void method bypasses the
Task pipeline entirely and immediately crashes the
application process via the AppDomain.UnhandledException
handler, making it impossible to catch via
TaskScheduler.UnobservedTaskException. Always return
Task or Task<T>.
Enforcing Strict Behavior (Crashing on Unobserved Exceptions)
If you prefer your application to fail fast during development or
testing when an unobserved task exception occurs, you can re-enable the
legacy crash behavior in your runtimeconfig.json file:
{
"runtimeOptions": {
"configProperties": {
"System.Threading.Tasks.TaskScheduler.ThrowUnobservedTaskExceptions": true
}
}
}When this flag is active, any unobserved task exception not marked as
observed via eventArgs.SetObserved() will crash the
process, making hidden failures immediately visible.