Skip to main content

IExceptionHandler in ASP.NET Core: Global Error Handling

Last updated: October 18, 2026 · Tested with .NET 10 (SDK 10.0.401, ASP.NET Core 10.0.12), compared with ASP.NET Core 9.0.14, Windows 11

Short answer: Implement IExceptionHandler, register it with builder.Services.AddExceptionHandler<T>(), add builder.Services.AddProblemDetails() and call app.UseExceptionHandler(). Handlers run in registration order until one returns true, so put the catch-all last. I built three handlers (validation → 400, not found → 404, everything else → 500) on .NET 10 and tested them with curl. Four results you should know before you ship:

  • Registering the global handler first turned every 404 into a 500.
  • Browser requests (Accept: text/html) got an empty 500 until I added a fallback.
  • The developer exception page never showed.
  • .NET 10 stopped logging exceptions your handler handles; .NET 9 logged them.

Why IExceptionHandler Instead of Custom Middleware

Before .NET 8, global error handling meant writing a middleware with a try/catch or passing a lambda to UseExceptionHandler. IExceptionHandler (in Microsoft.AspNetCore.Diagnostics, since .NET 8) gives you small, DI-friendly classes instead, each responsible for one kind of exception. The built-in exception handler middleware calls them and takes care of the rest: clearing the response, keeping the original exception in IExceptionHandlerFeature, and logging. No NuGet package is needed.

The Setup

using System.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails(o =>
    o.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Instance = $"{ctx.HttpContext.Request.Method} {ctx.HttpContext.Request.Path}";
        ctx.ProblemDetails.Extensions["traceId"] = Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
    });

// Order matters: handlers run in registration order until one returns true.
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

var app = builder.Build();

app.UseExceptionHandler();
app.UseStatusCodePages();

app.MapGet("/products/{id:int}", (int id) =>
    id == 42 ? Results.Ok(new { id, name = "Keyboard" }) : throw new NotFoundException($"Product {id} was not found."));

app.Run();

AddProblemDetails registers IProblemDetailsService, which the handlers use to write RFC 9457 problem JSON. CustomizeProblemDetails adds the request and a trace ID to every error response, so a user's error report can be matched to your logs. UseStatusCodePages makes plain 404s (unknown routes) return problem JSON too.

The Handlers

Each handler checks the exception type and returns false if it isn't its job. The middleware then tries the next one:

public sealed class NotFoundException(string message) : Exception(message);

public sealed class ValidationException(IDictionary<string, string[]> errors) : Exception("Validation failed")
{
    public IDictionary<string, string[]> Errors { get; } = errors;
}

public sealed class NotFoundExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception exception, CancellationToken ct)
    {
        if (exception is not NotFoundException) return false;

        http.Response.StatusCode = StatusCodes.Status404NotFound;
        return await problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = http,
            Exception = exception,
            ProblemDetails = new ProblemDetails
            {
                Title = "Resource not found",
                Detail = exception.Message,
                Status = StatusCodes.Status404NotFound
            }
        });
    }
}

The validation handler is the same shape, but writes a ValidationProblemDetails with the field errors and status 400. The catch-all handler logs and returns a generic 500. Its final version, with the browser fix explained below:

// Last resort: log once and return a generic 500 without exception details.
public sealed class GlobalExceptionHandler(IProblemDetailsService problemDetails, ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception exception, CancellationToken ct)
    {
        logger.LogError(exception, "Unhandled exception for {Method} {Path}", http.Request.Method, http.Request.Path);

        http.Response.StatusCode = StatusCodes.Status500InternalServerError;
        var problem = new ProblemDetails
        {
            Title = "An unexpected error occurred",
            Status = StatusCodes.Status500InternalServerError
        };

        if (await problemDetails.TryWriteAsync(new ProblemDetailsContext { HttpContext = http, Exception = exception, ProblemDetails = problem }))
            return true;

        // TryWriteAsync returns false when the client doesn't accept JSON (e.g. a browser sending Accept: text/html).
        // Write the problem anyway, so the response isn't an empty 500.
        await http.Response.WriteAsJsonAsync(problem, options: null, contentType: "application/problem+json", cancellationToken: ct);
        return true;
    }
}

Real Responses

Production environment, curl -i against the running app (headers trimmed):

$ curl -i http://localhost:5190/products/7
HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Resource not found","status":404,
 "detail":"Product 7 was not found.","instance":"GET /products/7",
 "traceId":"00-1f39f9d8795106faeef64238174a8860-a5df8bdd7757bcae-00"}

$ curl -i -X POST -H 'Content-Type: application/json' -d '{"name":"","price":0}' http://localhost:5190/products
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,
 "instance":"POST /products","errors":{"name":["Name is required."],"price":["Price must be greater than 0."]},
 "traceId":"00-2b1e7853f4c53538b4eb9bc913a2ec30-2b533007b0bcdbb4-00"}

$ curl -i http://localhost:5190/boom
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json

{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An unexpected error occurred","status":500,
 "instance":"GET /boom","traceId":"00-c9e8b88bd2a684ca8af7c6bd68b945f1-b615c563bfec5cae-00"}

$ curl -i http://localhost:5190/does-not-exist
HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,
 "instance":"GET /does-not-exist","traceId":"00-7ec7ef6f6e028bc3444cf2ad11e65c12-3864d615a854d060-00"}

The /boom endpoint throws InvalidOperationException("Connection string 'Db' has password=secret123"). None of that reaches the client. It appears only in the server log, from the catch-all handler:

fail: ErrorsApi.GlobalExceptionHandler[0]
      Unhandled exception for GET /boom
      System.InvalidOperationException: Connection string 'Db' has password=secret123

Never put exception.Message into Detail in a catch-all handler. Messages from libraries can contain connection strings, SQL and file paths. Only messages you wrote for users, like the NotFoundException above, belong in the response. More on what not to expose: API security best practices.

Gotcha 1: Registration Order Decides Everything

I swapped the order and registered GlobalExceptionHandler before NotFoundExceptionHandler:

$ curl -i http://localhost:5190/products/7
HTTP/1.1 500 Internal Server Error

{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An unexpected error occurred","status":500,...}

The catch-all returns true for everything, so the 404 handler is never asked. Register specific handlers first and the catch-all last. Nothing warns you about this at startup.

Gotcha 2: Browsers Got an Empty 500

My first catch-all simply returned the result of TryWriteAsync. That works for API clients, which send Accept: application/json or */*. A browser sends Accept: text/html:

$ curl -i -H 'Accept: text/html' http://localhost:5190/boom
HTTP/1.1 500 Internal Server Error
Content-Length: 0

IProblemDetailsService.TryWriteAsync only writes when the client accepts JSON. Otherwise it returns false, the handler returns false, and the middleware falls back to an empty 500. It also logs the exception a second time as unhandled. With the fallback WriteAsJsonAsync shown in the final handler above:

$ curl -i -H 'Accept: text/html' http://localhost:5190/boom
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json

{"title":"An unexpected error occurred","status":500}

The fallback body skips CustomizeProblemDetails, so type, instance and traceId are missing. Add them to the object yourself if you need them there. If you serve HTML pages, render an error page here instead.

Gotcha 3: .NET 10 No Longer Logs Handled Exceptions

Same app, same requests (/products/7 and /boom), run on ASP.NET Core 9.0.14 and 10.0.12. The .NET 9 log:

fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
      An unhandled exception has occurred while executing the request.
      ErrorsApi.NotFoundException: Product 7 was not found.
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
      An unhandled exception has occurred while executing the request.
      System.InvalidOperationException: Connection string 'Db' has password=secret123
fail: ErrorsApi.GlobalExceptionHandler[0]
      Unhandled exception for GET /boom
      System.InvalidOperationException: Connection string 'Db' has password=secret123

On .NET 10, only the last entry, from my own handler, appears. The middleware no longer logs (or emits diagnostics for) exceptions that an IExceptionHandler handled. The 404 now leaves no trace in the log at all. That's less noise, but if your alerting counted ExceptionHandlerMiddleware errors, those counts drop after the upgrade. .NET 10 adds ExceptionHandlerOptions.SuppressDiagnosticsCallback (I checked with reflection: it isn't on .NET 9) to restore the old behavior:

app.UseExceptionHandler(new ExceptionHandlerOptions { SuppressDiagnosticsCallback = _ => false });

With that line the .NET 10 app logged the 404 again (ExceptionHandlerMiddleware[1] ... NotFoundException: Product 7 was not found.). The better fix is usually to log in your handlers, as the catch-all above does, at the level each exception deserves. For structured logging, see Serilog in .NET.

Gotcha 4: The Developer Exception Page Never Appears

In the Development environment, the same /boom request returned the same generic problem JSON, not the developer exception page. WebApplication adds the developer page as the outermost middleware in Development. But UseExceptionHandler runs inside it, catches the exception first, and your handlers turn it into a normal response. If you want the stack trace page while developing, register the handler middleware only outside Development:

if (!app.Environment.IsDevelopment())
    app.UseExceptionHandler();

That also means your handlers don't run in Development, so test them with the Production environment (ASPNETCORE_ENVIRONMENT=Production), as I did here.

Gotcha 5: Errors After the Response Has Started

An endpoint that writes and flushes part of the body, then throws:

$ curl -i http://localhost:5190/stream
HTTP/1.1 200 OK
Content-Type: text/plain

partial output...
warn: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[2]
      The response has already started, the error handler will not be executed.

The client got a 200 and a truncated body. No handler can change a status code that's already been sent. For streaming endpoints, validate everything before writing the first byte.

Startup Errors You May Hit

UseExceptionHandler() with no AddProblemDetails() and no handler options stops the app at startup:

System.InvalidOperationException: An error occurred when configuring the exception handler middleware. Either the 'ExceptionHandlingPath' or the 'ExceptionHandler' property must be set in 'UseExceptionHandler()'. Alternatively, set one of the aforementioned properties in 'Startup.ConfigureServices' as follows: 'services.AddExceptionHandler(options => { ... });' or configure to generate a 'ProblemDetails' response in 'service.AddProblemDetails()'.

Handlers that inject IProblemDetailsService without AddProblemDetails() fail the same way, with a DI error:

System.InvalidOperationException: Unable to resolve service for type 'Microsoft.AspNetCore.Http.IProblemDetailsService' while attempting to activate 'ErrorsApi.ValidationExceptionHandler'.

Fix for both: call builder.Services.AddProblemDetails(). With AddProblemDetails() and no custom handlers at all, unhandled exceptions get a default problem response, "title":"An error occurred while processing your request.","status":500, which is a reasonable minimum for any API.

A Simpler Option for Status Codes Only: StatusCodeSelector

If all you need is "this exception type → that status code", ASP.NET Core 9 and later have ExceptionHandlerOptions.StatusCodeSelector:

app.UseExceptionHandler(new ExceptionHandlerOptions
{
    StatusCodeSelector = ex => ex switch
    {
        NotFoundException => StatusCodes.Status404NotFound,
        ValidationException => StatusCodes.Status400BadRequest,
        _ => StatusCodes.Status500InternalServerError
    }
});

Tested on .NET 10 with no handlers registered: /products/7 returned 404 with the default "title":"Not Found" problem body. When handlers are registered too, a handler that returns true wins: the same request then got my handler's "title":"Resource not found". Use IExceptionHandler when the response body differs per exception, like the validation errors above, or when handlers need injected services.

Checklist

  • AddProblemDetails(), then specific handlers, then the catch-all, then UseExceptionHandler().
  • Catch-all returns a generic message; no exception.Message in public responses.
  • A fallback write when TryWriteAsync returns false.
  • A trace ID in every error response, and the same ID in your logs.
  • After upgrading to .NET 10, check alerts that counted ExceptionHandlerMiddleware errors.
  • Test handlers with ASPNETCORE_ENVIRONMENT=Production.
  • Validate streaming endpoints before writing the response.

FAQ

Do I need a NuGet package for IExceptionHandler?
No. It's in the ASP.NET Core shared framework (Microsoft.AspNetCore.Diagnostics) since .NET 8.

Can I register more than one IExceptionHandler?
Yes. They run in registration order, and the first one to return true wins.

Does IExceptionHandler work with controllers and minimal APIs?
Yes. It's middleware-level, so it catches exceptions from controllers, minimal API endpoints and other middleware registered after UseExceptionHandler. The endpoints in this article are minimal APIs.

Should I throw exceptions for validation and not-found?
It's a design choice. Exceptions keep endpoints short. Returning results (Results.NotFound(), Results.ValidationProblem()) is cheaper and more explicit. Either way, keep a catch-all handler for the exceptions you didn't plan for. For general exception design in C#, see C# exception handling best practices.

Conclusion

The setup is small: AddProblemDetails, a few IExceptionHandler classes, and UseExceptionHandler. The four things that bit me in testing are worth checking in your own app:

  • handler order;
  • empty 500s for browsers;
  • the developer page disappearing;
  • .NET 10 dropping the middleware's log line for handled exceptions.

References: Handle errors in ASP.NET Core · Handle errors in ASP.NET Core APIs · IExceptionHandler API reference · .NET 10 breaking change: exception diagnostics suppressed · RFC 9457: Problem Details for HTTP APIs

This article is part of the ASP.NET Core tutorials guide (APIs, security, observability and deployment).

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...