Skip to main content
SEMastery
ASP.NETintermediate

Global Error Handling in ASP.NET Core: From Middleware to Modern Handlers

Learn global error handling in ASP.NET Core step by step: from try-catch middleware to IExceptionHandler and Problem Details, with simple diagrams and clear code.

13 min readUpdated November 10, 2025

A kitchen with one complaints desk

Imagine a busy restaurant. Many cooks work in the kitchen. Sometimes a dish burns. Sometimes the rice runs out. Sometimes a plate slips and breaks. Things go wrong, that is just normal in a busy kitchen.

Now picture two ways the restaurant could deal with problems.

In the first way, every single cook has to walk out to the customer and explain what went wrong. One cook shouts about the burnt dish, another mumbles about the rice. Each one says it in their own messy way. The customer is confused and the kitchen is chaos.

In the second way, there is one calm complaints desk near the door. Whenever anything goes wrong, the problem is sent to that desk. The person there always says the same polite thing: "Sorry, there was a small issue, here is what happened and here is your token number." Clean. Calm. The same every time.

Global error handling in ASP.NET Core is that one calm complaints desk. Instead of every controller and endpoint handling its own errors in its own messy way, you build one place that catches every problem and answers in one clear, friendly format. That is what we will build in this article, starting from the old way and moving to the modern way.

Why we even need this

When code runs, sometimes it throws an exception. An exception is .NET's way of saying "I cannot continue, something is wrong." Maybe a database is down. Maybe a user sent bad data. Maybe a bug divided a number by zero.

If you do nothing, ASP.NET Core will still send a response, but it may be ugly. In development it shows a big stack trace. In production it sends a bare 500 with no helpful detail. Worse, a stack trace can leak secrets about how your app works, which is a security risk.

So we want three things from good error handling:

  • One place to handle errors, so we do not repeat ourselves.
  • One shape for every error response, so clients always know what to expect.
  • Safety, so we never leak stack traces or secrets to users.

Here is the big picture of where errors come from and where they should all end up.

Errors can come from many places, but global handling funnels them all into one handler that returns one consistent response.

The old way: try-catch middleware

Before the modern tools existed, people wrote their own middleware to catch errors. Middleware is a small piece of code that sits in the request pipeline. Each request passes through it on the way in and on the way out.

The trick is simple. You wrap the call to the next middleware in a try-catch. If anything later in the pipeline throws, your catch block runs. Because this middleware sits near the top, it can catch errors from everything below it.

Here is a basic version.

public class ErrorHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ErrorHandlingMiddleware> _logger;
 
    public ErrorHandlingMiddleware(
        RequestDelegate next,
        ILogger<ErrorHandlingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }
 
    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context); // run everything below us
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception");
 
            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
            context.Response.ContentType = "application/json";
 
            await context.Response.WriteAsJsonAsync(new
            {
                title = "Something went wrong",
                status = 500
            });
        }
    }
}

You then add it to the pipeline near the top:

app.UseMiddleware<ErrorHandlingMiddleware>();

This works, and for many years it was the normal pattern. But it has small traps. If the response has already started sending, you cannot change the status code, and the code above will throw a second time. You also end up writing the JSON shape by hand, which is easy to get wrong. And testing it means spinning up the whole pipeline.

Let us see how a request flows through this middleware when something breaks.

How try-catch middleware catches an error

Request in
Call next
Endpoint throws
Catch block
Write JSON

Steps

1

Request in

Enters the error middleware first

2

Call next

await _next runs later steps

3

Endpoint throws

A bug or bad data raises an exception

4

Catch block

Control jumps back up to catch

5

Write JSON

Send a safe error response

The middleware wraps the rest of the pipeline. When a later step throws, control jumps to the catch block.

The modern way: IExceptionHandler

Starting in .NET 8, ASP.NET Core gave us a much cleaner tool: the IExceptionHandler interface. Instead of writing your own middleware, you write a small class with one method, and the framework's built-in middleware calls it for you.

The interface has a single method, TryHandleAsync. You return true if you handled the error, or false to let the next handler try. This little true/false design is powerful, because you can register many handlers and each one can handle just the errors it understands.

Here is a clean handler that turns any exception into a safe response.

public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;
    private readonly IProblemDetailsService _problemDetailsService;
 
    public GlobalExceptionHandler(
        ILogger<GlobalExceptionHandler> logger,
        IProblemDetailsService problemDetailsService)
    {
        _logger = logger;
        _problemDetailsService = problemDetailsService;
    }
 
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        _logger.LogError(exception, "Unhandled exception occurred");
 
        httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
 
        return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            Exception = exception,
            ProblemDetails = new ProblemDetails
            {
                Title = "Server error",
                Detail = "An unexpected error happened. Please try again later.",
                Status = StatusCodes.Status500InternalServerError
            }
        });
    }
}

Now wire it up in Program.cs:

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
 
var app = builder.Build();
 
app.UseExceptionHandler();
 
app.MapControllers();
app.Run();

Notice how short and tidy that is. AddProblemDetails turns on the standard error format. AddExceptionHandler registers your class. UseExceptionHandler adds the built-in middleware that calls your handler. No hand-written try-catch, no manual JSON shape.

Here is what happens inside the framework when an exception bubbles up.

The built-in middleware catches the exception and asks each registered handler in turn until one returns true.

Chaining many handlers

Real apps have different kinds of errors. A "user not found" is not the same as a "you sent bad data" or a "the server crashed." You want different status codes and messages for each.

The nice part of IExceptionHandler is that you can register several handlers. The middleware calls them in the order you registered them. The first one that returns true wins. If a handler does not know the exception type, it returns false and the next handler gets a turn.

Think of it like a row of help desks. Each desk handles one kind of problem. If the first desk cannot help, it points you to the next.

public class NotFoundExceptionHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (exception is not NotFoundException notFound)
        {
            return false; // not my job, let the next handler try
        }
 
        httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
        await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Title = "Not found",
            Detail = notFound.Message,
            Status = StatusCodes.Status404NotFound
        });
        return true;
    }
}

Register the specific handlers first and the catch-all last:

builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); // catch-all

Order matters a lot here. Put the most specific handler first and the general catch-all last, so each error is matched by the narrowest handler that understands it.

A chain of exception handlers

NotFound?
Validation?
Catch-all
Response

Steps

1

NotFound?

Handles NotFoundException, else false

2

Validation?

Handles bad input, else false

3

Catch-all

Handles anything left over

4

Response

First true handler writes the reply

Each handler checks if the exception is its type. If not, it returns false and the next one tries.

This table shows a common mapping from exception type to response.

Exception typeHTTP statusMeaning for the user
ValidationException400 Bad RequestYou sent data that is not valid
NotFoundException404 Not FoundThe thing you asked for does not exist
UnauthorizedException401 UnauthorizedYou are not logged in
ForbiddenException403 ForbiddenYou are logged in but not allowed
Anything else500 Server ErrorA bug or unexpected failure on our side

What is Problem Details?

We keep mentioning Problem Details, so let us slow down and explain it. Problem Details is a small standard, defined in RFC 9457 (which updated the older RFC 7807). It says: when an API returns an error, use this exact JSON shape. That way every client in the world knows how to read it.

A Problem Details response looks like this:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "Bad Request",
  "status": 400,
  "detail": "The 'email' field is required.",
  "instance": "/api/users"
}

The fields are simple to remember:

FieldWhat it means
typeA link that describes this kind of error
titleA short human-friendly summary
statusThe HTTP status code, like 400 or 500
detailA specific message about this one error
instanceWhich URL or request had the problem

ASP.NET Core builds this for you. Calling AddProblemDetails registers a service called IProblemDetailsService. When you call its TryWriteAsync method, it fills in the standard fields and writes the JSON. You can also add your own extra fields, like a traceId to help support staff find the log entry.

The flow below shows how the pieces fit together from start to finish.

Setup wires Problem Details and your handler; at request time the handler uses the service to write a standard response.

A note on logs in .NET 10

There is one helpful change worth knowing about. In .NET 8 and .NET 9, the exception middleware always wrote diagnostic logs and metrics, even when your handler fully dealt with the error. That sometimes led to duplicate logs: one from your handler and one from the framework.

Starting in .NET 10, the default changed. When your TryHandleAsync returns true, the middleware no longer emits its own diagnostics by default. This avoids the double logging. If you still want the framework to log handled exceptions in some cases, you can control it with the new SuppressDiagnosticsCallback property on ExceptionHandlerOptions. The callback receives the exception and request, so you decide case by case.

For most teams the new default is exactly what you want, so you do not have to do anything special.

Keeping errors safe

The most important rule of error handling is short: never leak secrets to the user. A stack trace can tell an attacker which libraries you use, where your files live, and how your code is shaped. That is gold for someone trying to break in.

Follow these habits:

  • Show full details (stack traces, the developer exception page) only in development.
  • In production, send a generic, friendly message and a traceId.
  • Log the full exception on the server, where only you can read it.
  • Let support match the user's traceId to the detailed server log.

This way the user gets a calm message, and you still keep every detail you need to fix the bug. The user sees "Sorry, error 500, your reference is abc123." You see the full story in your logs.

Putting it all together

Here is a clear comparison of the two approaches we covered, so you can choose with confidence.

PointCustom middlewareIExceptionHandler
Lines of codeMore, and easy to get wrongLess, and tidy
Built-in to frameworkNo, you write itYes, since .NET 8
Works with Problem DetailsOnly if you wire it by handYes, out of the box
Easy to testHarderEasier, it is a small class
Multiple handlersYou build the chain yourselfBuilt in, register in order
Recommended todayOnly for special casesYes, the default choice

For almost every new app, the answer is clear: use IExceptionHandler with AddProblemDetails and UseExceptionHandler. Reach for custom middleware only when you have a need the built-in path truly cannot meet.

A simple checklist before you ship

When you set up global error handling, walk through these points:

  • Is UseExceptionHandler placed early in the pipeline, near the top?
  • Did you call AddProblemDetails so responses follow the standard?
  • Do you have specific handlers for known errors (404, 400) and one catch-all?
  • Are specific handlers registered before the catch-all?
  • Do production responses hide stack traces and secrets?
  • Do you log the full exception on the server with a traceId?

If you can tick every box, your "complaints desk" is calm, consistent, and safe.

Quick recap

  • Global error handling gives you one place to catch every error and one shape for every response, just like a single calm complaints desk in a busy kitchen.
  • The old way was custom try-catch middleware. It works but is easy to get wrong and hard to test.
  • The modern way is IExceptionHandler, added in .NET 8. You write one small class with a TryHandleAsync method and register it with AddExceptionHandler.
  • You can chain many handlers. Each returns true if it handled the error or false to pass it on. Register specific handlers first and the catch-all last.
  • Problem Details (RFC 9457) is the standard JSON error shape. Turn it on with AddProblemDetails and write it through IProblemDetailsService.
  • In .NET 10, the middleware no longer double-logs handled exceptions by default; use SuppressDiagnosticsCallback if you need finer control.
  • Always keep errors safe: full detail in logs on the server, a friendly message and traceId for the user.

References and further reading

Related Posts