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.
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.
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
Steps
Request in
Enters the error middleware first
Call next
await _next runs later steps
Endpoint throws
A bug or bad data raises an exception
Catch block
Control jumps back up to catch
Write JSON
Send a safe error response
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.
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-allOrder 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
Steps
NotFound?
Handles NotFoundException, else false
Validation?
Handles bad input, else false
Catch-all
Handles anything left over
Response
First true handler writes the reply
This table shows a common mapping from exception type to response.
| Exception type | HTTP status | Meaning for the user |
|---|---|---|
ValidationException | 400 Bad Request | You sent data that is not valid |
NotFoundException | 404 Not Found | The thing you asked for does not exist |
UnauthorizedException | 401 Unauthorized | You are not logged in |
ForbiddenException | 403 Forbidden | You are logged in but not allowed |
| Anything else | 500 Server Error | A 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:
| Field | What it means |
|---|---|
type | A link that describes this kind of error |
title | A short human-friendly summary |
status | The HTTP status code, like 400 or 500 |
detail | A specific message about this one error |
instance | Which 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.
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
traceIdto 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.
| Point | Custom middleware | IExceptionHandler |
|---|---|---|
| Lines of code | More, and easy to get wrong | Less, and tidy |
| Built-in to framework | No, you write it | Yes, since .NET 8 |
| Works with Problem Details | Only if you wire it by hand | Yes, out of the box |
| Easy to test | Harder | Easier, it is a small class |
| Multiple handlers | You build the chain yourself | Built in, register in order |
| Recommended today | Only for special cases | Yes, 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
UseExceptionHandlerplaced early in the pipeline, near the top? - Did you call
AddProblemDetailsso 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-catchmiddleware. 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 aTryHandleAsyncmethod and register it withAddExceptionHandler. - You can chain many handlers. Each returns
trueif it handled the error orfalseto 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
AddProblemDetailsand write it throughIProblemDetailsService. - In .NET 10, the middleware no longer double-logs handled exceptions by default; use
SuppressDiagnosticsCallbackif you need finer control. - Always keep errors safe: full detail in logs on the server, a friendly message and
traceIdfor the user.
References and further reading
- Handle errors in ASP.NET Core — Microsoft Learn
- Handle errors in ASP.NET Core APIs — Microsoft Learn
- Global Error Handling in ASP.NET Core: From Middleware to Modern Handlers — Milan Jovanović
- Global Exception Handling in ASP.NET Core — The Complete Guide for .NET 10 — codewithmukesh
- Problem Details for ASP.NET Core APIs — Milan Jovanović
Related Posts
3 Ways To Create Middleware In ASP.NET Core (Beginner Guide)
Learn the 3 ways to create middleware in ASP.NET Core: inline request delegates, convention-based classes, and factory-based IMiddleware, with simple diagrams and code.
Best Practices for Building REST APIs in ASP.NET Core
A friendly, beginner guide to REST API best practices in ASP.NET Core with naming, status codes, validation, ProblemDetails, paging, versioning, security, and code.
Adding Validation to the Options Pattern in ASP.NET Core
Learn to validate the options pattern in ASP.NET Core using data annotations, IValidateOptions, ValidateOnStart, and source generation, with simple examples and diagrams.
Problem Details for ASP.NET Core APIs: A Beginner's Guide
Learn Problem Details in ASP.NET Core step by step. Give your API one clean error format with RFC 9457, AddProblemDetails, and IExceptionHandler in .NET 10.
Global Error Handling in ASP.NET Core 8 (Beginner Guide)
Learn global error handling in ASP.NET Core 8 with IExceptionHandler, ProblemDetails, and UseExceptionHandler, explained with simple diagrams and clear code.
How to Create Custom Middlewares in ASP.NET Core (Step by Step)
Learn to build custom middleware in ASP.NET Core three ways: inline Use, a convention class, and IMiddleware with DI. Beginner friendly, with clear examples.