Introduction
A global exception handler catches unhandled exceptions that escape all try/catch blocks, preventing your application from crashing silently. In .NET, the approach depends on the application type: console apps use AppDomain.UnhandledException, WPF uses DispatcherUnhandledException, WinForms uses Application.ThreadException, and ASP.NET Core uses exception-handling middleware. Each provides a centralized place to log the error, notify monitoring systems, and display a user-friendly message instead of a stack trace. This article covers the idiomatic global exception handler for each .NET application type.
Console Applications
1class Program
2{
3 static void Main(string[] args)
4 {
5 // Catch all unhandled exceptions
6 AppDomain.CurrentDomain.UnhandledException += (sender, e) =>
7 {
8 var ex = (Exception)e.ExceptionObject;
9 Console.Error.WriteLine($"Fatal error: {ex.Message}");
10 Console.Error.WriteLine(ex.StackTrace);
11 // Log to file, Sentry, Application Insights, etc.
12 Environment.Exit(1);
13 };
14
15 // Catch unobserved Task exceptions
16 TaskScheduler.UnobservedTaskException += (sender, e) =>
17 {
18 Console.Error.WriteLine($"Unobserved task exception: {e.Exception.Message}");
19 e.SetObserved(); // Prevents process termination
20 };
21
22 Run();
23 }
24
25 static void Run()
26 {
27 throw new InvalidOperationException("Something went wrong");
28 }
29}
AppDomain.UnhandledException fires for any unhandled exception on any thread. The process still terminates after the handler runs — this event is for logging, not recovery.
ASP.NET Core Web Applications
Exception-Handling Middleware (Recommended)
1// Program.cs
2var builder = WebApplication.CreateBuilder(args);
3var app = builder.Build();
4
5if (app.Environment.IsDevelopment())
6{
7 app.UseDeveloperExceptionPage(); // Detailed error page
8}
9else
10{
11 app.UseExceptionHandler("/error"); // Custom error page
12}
13
14app.MapGet("/", () =>
15{
16 throw new InvalidOperationException("Test error");
17});
18
19app.MapGet("/error", () => Results.Problem(
20 title: "An error occurred",
21 statusCode: 500
22));
23
24app.Run();
Custom Exception-Handling Middleware
1public class GlobalExceptionMiddleware
2{
3 private readonly RequestDelegate _next;
4 private readonly ILogger<GlobalExceptionMiddleware> _logger;
5
6 public GlobalExceptionMiddleware(RequestDelegate next,
7 ILogger<GlobalExceptionMiddleware> logger)
8 {
9 _next = next;
10 _logger = logger;
11 }
12
13 public async Task InvokeAsync(HttpContext context)
14 {
15 try
16 {
17 await _next(context);
18 }
19 catch (Exception ex)
20 {
21 _logger.LogError(ex, "Unhandled exception for {Method} {Path}",
22 context.Request.Method, context.Request.Path);
23
24 context.Response.StatusCode = ex switch
25 {
26 ArgumentException => 400,
27 UnauthorizedAccessException => 403,
28 FileNotFoundException => 404,
29 _ => 500
30 };
31
32 await context.Response.WriteAsJsonAsync(new
33 {
34 error = ex.Message,
35 statusCode = context.Response.StatusCode
36 });
37 }
38 }
39}
40
41// Register in Program.cs
42app.UseMiddleware<GlobalExceptionMiddleware>();
IExceptionHandler (.NET 8+)
1public class AppExceptionHandler : IExceptionHandler
2{
3 private readonly ILogger<AppExceptionHandler> _logger;
4
5 public AppExceptionHandler(ILogger<AppExceptionHandler> logger)
6 {
7 _logger = logger;
8 }
9
10 public async ValueTask<bool> TryHandleAsync(
11 HttpContext context, Exception exception, CancellationToken ct)
12 {
13 _logger.LogError(exception, "Unhandled exception");
14
15 context.Response.StatusCode = 500;
16 await context.Response.WriteAsJsonAsync(new
17 {
18 error = "An internal error occurred"
19 }, ct);
20
21 return true; // Exception was handled
22 }
23}
24
25// Program.cs
26builder.Services.AddExceptionHandler<AppExceptionHandler>();
27app.UseExceptionHandler();
WPF Applications
1public partial class App : Application
2{
3 protected override void OnStartup(StartupEventArgs e)
4 {
5 base.OnStartup(e);
6
7 // UI thread exceptions
8 DispatcherUnhandledException += (sender, args) =>
9 {
10 MessageBox.Show($"An error occurred: {args.Exception.Message}",
11 "Error", MessageBoxButton.OK, MessageBoxImage.Error);
12 // Log the exception
13 args.Handled = true; // Prevents app from crashing
14 };
15
16 // Non-UI thread exceptions
17 AppDomain.CurrentDomain.UnhandledException += (sender, args) =>
18 {
19 var ex = (Exception)args.ExceptionObject;
20 // Log — cannot prevent termination here
21 };
22
23 // Task exceptions
24 TaskScheduler.UnobservedTaskException += (sender, args) =>
25 {
26 // Log
27 args.SetObserved();
28 };
29 }
30}
Setting args.Handled = true in DispatcherUnhandledException prevents the application from terminating, allowing recovery.
1static class Program
2{
3 [STAThread]
4 static void Main()
5 {
6 Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
7
8 // UI thread exceptions
9 Application.ThreadException += (sender, e) =>
10 {
11 MessageBox.Show($"Error: {e.Exception.Message}",
12 "Application Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
13 // Log the exception
14 };
15
16 // Non-UI thread exceptions
17 AppDomain.CurrentDomain.UnhandledException += (sender, e) =>
18 {
19 var ex = (Exception)e.ExceptionObject;
20 // Log — process will terminate
21 };
22
23 Application.EnableVisualStyles();
24 Application.SetCompatibleTextRenderingDefault(false);
25 Application.Run(new MainForm());
26 }
27}
Worker Services / Background Services
1public class MyWorker : BackgroundService
2{
3 private readonly ILogger<MyWorker> _logger;
4
5 public MyWorker(ILogger<MyWorker> logger)
6 {
7 _logger = logger;
8 }
9
10 protected override async Task ExecuteAsync(CancellationToken stoppingToken)
11 {
12 while (!stoppingToken.IsCancellationRequested)
13 {
14 try
15 {
16 await DoWorkAsync(stoppingToken);
17 await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
18 }
19 catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
20 {
21 // Normal shutdown
22 }
23 catch (Exception ex)
24 {
25 _logger.LogError(ex, "Worker failed, retrying in 30s");
26 await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
27 }
28 }
29 }
30
31 private async Task DoWorkAsync(CancellationToken ct)
32 {
33 // Work that might throw
34 }
35}
Common Pitfalls
Catching Exception everywhere instead of using a global handler: Wrapping every method in try/catch clutters the code and often swallows exceptions silently. Use specific try/catch only where you can meaningfully recover, and let the global handler catch everything else.
Exposing stack traces to end users in production: Returning ex.StackTrace in API responses or showing it in UI error dialogs leaks internal implementation details. Log the full exception server-side and return a generic error message to clients.
Forgetting TaskScheduler.UnobservedTaskException: Exceptions in fire-and-forget Task operations are not caught by AppDomain.UnhandledException. Without UnobservedTaskException, these errors are silently swallowed (in .NET Core) or crash the process (in .NET Framework with ThrowUnobservedTaskExceptions enabled).
Setting args.Handled = true unconditionally in WPF: While this prevents crashes, it also suppresses exceptions that indicate unrecoverable state (like StackOverflowException or OutOfMemoryException). Only set Handled = true for exceptions you can actually recover from.
Not logging enough context with the exception: Logging just ex.Message loses the stack trace, inner exceptions, and request context. Always log the full exception object and relevant context (request path, user ID, input parameters) for effective debugging.
Summary
Console apps: use AppDomain.CurrentDomain.UnhandledException and TaskScheduler.UnobservedTaskException
ASP.NET Core: use app.UseExceptionHandler() middleware or custom IExceptionHandler (.NET 8+)
WPF: use DispatcherUnhandledException with args.Handled = true for recoverable errors
WinForms: use Application.ThreadException with SetUnhandledExceptionMode(CatchException)
Always log the full exception with context, and never expose stack traces to end users in production