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.