Global exception handling across ASP.NET Core, worker services and the runtime: last-chance hooks, ProblemDetails, and what each mechanism can and cannot do.
In ASP.NET Core, register AddProblemDetails(), one or more IExceptionHandler implementations (.NET 8+) with AddExceptionHandler<T>(), and app.UseExceptionHandler() early in the pipeline. Handlers log once and map known exceptions to the right status code and an RFC 9457 ProblemDetails body, and never leak stack traces in production. For the process as a whole, AppDomain.CurrentDomain.UnhandledException is a last-chance notification: you can log and flush, but the process still terminates. TaskScheduler.UnobservedTaskException fires when a faulted task that nobody awaited is garbage-collected; it does not crash the process by default. In a BackgroundService, an unhandled exception in ExecuteAsync stops the host by default (.NET 6+). async void exceptions go to the synchronisation context and usually crash the process, so avoid async void except for event handlers.
A good global strategy has layers: handle expected failures close to where they happen (often without exceptions), let unexpected exceptions bubble to a single boundary per entry point that logs once, returns a safe, consistent response (ProblemDetails) and keeps the process healthy — and make sure background work and fire-and-forget code can't fail silently or crash the host. I'd walk through each mechanism and where it fits.
UseExceptionHandler, IExceptionHandler, ProblemDetailsbuilder.Services.AddProblemDetails(o =>
o.CustomizeProblemDetails = ctx =>
ctx.ProblemDetails.Extensions["traceId"] = Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier);
builder.Services.AddExceptionHandler<ValidationExceptionHandler>(); // order matters
builder.Services.AddExceptionHandler<ConflictExceptionHandler>();
builder.Services.AddExceptionHandler<FallbackExceptionHandler>(); // logs + 500
var app = builder.Build();
app.UseExceptionHandler(); // first in the pipeline so it wraps everything after it
app.UseStatusCodePages(); // ProblemDetails for 404/405 etc. without bodies
UseExceptionHandler is middleware that catches unhandled exceptions from later middleware/endpoints, clears the response (if not started), and re-executes to produce an error response.IExceptionHandler (.NET 8) lets you register multiple handlers in DI; each TryHandleAsync returns true if it handled the exception. Typical handlers map domain exceptions to status codes (404, 409, 422) and a fallback logs and returns 500.ProblemDetails (RFC 9457, formerly 7807) gives a consistent JSON error shape: type, title, status, detail, instance, plus extensions such as traceId and validation errors. IProblemDetailsService writes it and respects content negotiation.UseDeveloperExceptionPage is only for Development.AppDomain.CurrentDomain.UnhandledException: raised when an exception is unhandled on any thread. It's a notification, not a handler — the process will still terminate. Use it to log/flush telemetry (e.g. Log.CloseAndFlush()), not to recover.TaskScheduler.UnobservedTaskException: raised when a faulted Task is garbage-collected without its exception being observed (fire-and-forget). Since .NET 4.5 it doesn't crash the process by default; call e.SetObserved() after logging. Its timing depends on GC, so it's a safety net, not a mechanism.BackgroundService failuresExecuteAsync stops the host by default (BackgroundServiceExceptionBehavior.StopHost), logging the error. Before .NET 6 the service died silently while the app kept running.protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var message in _channel.Reader.ReadAllAsync(stoppingToken))
{
try
{
await _processor.HandleAsync(message, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break; // shutting down
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process message {MessageId}", message.Id);
await _deadLetter.SendAsync(message, ex, stoppingToken); // don't lose it
}
}
}
ExecuteAsync (before the first await) blocks host start-up — begin with await Task.Yield() or keep the start fast.async voidExceptions from an async void method can't be caught by the caller — they're posted to the SynchronizationContext or, in ASP.NET Core/console apps, rethrown on the thread pool, which crashes the process via UnhandledException. Rules: never write async void except for event handlers, and wrap their bodies in try/catch. Analysers (VSTHRD100, CA2012-adjacent rules) flag it.
_ = Task.Run(...) or un-awaited async calls lose exceptions (and, in ASP.NET Core, may outlive the request's scoped services — ObjectDisposedException on DbContext). Use a background queue (Channel<T> + BackgroundService) with its own error handling, or a durable queue.RpcException status codes. SignalR: hub filters.ValidationProblemDetails (400/422).IExceptionHandlers to stable ProblemDetails types.ProblemDetails.traceId in responses so support can find logs; OpenTelemetry/Application Insights link logs, traces and exceptions.UnhandledException and UnobservedTaskException logging, with log flushing.An API returned three different error shapes (MVC's default, a custom { error: "..." } from a middleware, and raw exception text in some minimal API endpoints), and a background email worker had died silently months earlier because an exception escaped ExecuteAsync under .NET 5 — nobody noticed until customers complained about missing invoices. We standardised on AddProblemDetails + IExceptionHandlers, added traceId to every error response, upgraded the worker (.NET 8's StopHost behaviour plus per-message try/catch and dead-lettering), added a health check reporting the worker's last successful run, and set Application Insights alerts on failed-request rate and on worker health.
Checks production experience with error handling at the application level.
IExceptionHandler and ProblemDetails, knows UnhandledException is notification only, explains UnobservedTaskException and BackgroundService behaviour, avoids async void, mentions not leaking details and logging once.ExceptionHandlerMiddleware worksnext(context) inside a try/catch.IExceptionHandlerFeature/IExceptionHandlerPathFeature (with the original path and endpoint).IExceptionHandlers in registration order until one returns true.ExceptionHandler delegate or re-executes the pipeline at ExceptionHandlingPath (e.g. /error), or writes a default ProblemDetails via IProblemDetailsService when AddProblemDetails is registered.ExceptionHandlerOptions.SuppressDiagnosticsCallback in .NET 10, which lets you skip logging for handled exceptions).Because it re-executes the pipeline, error endpoints must not depend on state that caused the failure, and they must handle any HTTP method.
ProblemDetails in detail{
"type": "https://errors.example.com.au/order-conflict",
"title": "Order already exists",
"status": 409,
"detail": "An order with this idempotency key was already submitted.",
"instance": "/orders",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
type is the stable machine-readable identifier clients should switch on (a URI, ideally pointing to documentation).ValidationProblemDetails adds an errors dictionary (field → messages), produced automatically by [ApiController] model validation and minimal API validation (.NET 10 adds built-in validation for minimal APIs).TypedResults.Problem(...) / Results.ValidationProblem(...) produce them from endpoints for expected outcomes.| Source | What happens |
|---|---|
| Request pipeline | Caught by Kestrel/hosting, logged, connection gets 500 (or exception middleware handles it) |
async Task method not awaited |
Exception stored in task; if never observed → UnobservedTaskException on GC |
async void method |
Rethrown on thread pool → AppDomain.UnhandledException → process terminates |
Thread / timer callback (System.Threading.Timer) |
Unhandled → process terminates |
BackgroundService.ExecuteAsync |
.NET 6+: logged, host stops (configurable to Ignore) |
IHostedService.StartAsync |
Host fails to start |
| Static constructor | TypeInitializationException on every access |
Environment.FailFast |
Immediate termination, no finally blocks |
builder.Services.Configure<HostOptions>(o =>
{
o.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.StopHost; // default
o.ShutdownTimeout = TimeSpan.FromSeconds(30); // time for graceful shutdown (draining)
});
Ignore restores the old behaviour (worker stops, app continues) — dangerous unless the worker is optional and health checks report it.
In Kubernetes or Azure Container Apps, stopping the host leads to a restart, which is often the healthiest recovery for corrupted state. Combine with readiness/liveness probes and alerts on restart counts.
AddAspNetCoreInstrumentation(o => o.RecordException = true) records exceptions as span events; Activity.Current?.AddException(ex) for custom spans (.NET 9).TelemetryClient.TrackException for handled exceptions you want visible.http.server.request.duration with error.type attribute (.NET 8 metrics) lets you alert on error rates by exception type without parsing logs.A global handler is not a licence to keep running in a corrupted state. Guidelines:
OutOfMemoryException) → fail fast and let the orchestrator restart.Environment.FailFast for truly unrecoverable states where continuing could corrupt data.IExceptionHandlers and AddProblemDetails; throw different exceptions and inspect responses in Development vs Production.ExecuteAsync in a BackgroundService and observe host shutdown; then switch the behaviour to Ignore and add a health check.async void method that throws in a console app and watch the process terminate.GC.Collect(); GC.WaitForPendingFinalizers(); and log the UnobservedTaskException event.var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails(o =>
o.CustomizeProblemDetails = ctx =>
ctx.ProblemDetails.Extensions["traceId"] = ctx.HttpContext.TraceIdentifier);
builder.Services.AddExceptionHandler<DomainExceptionHandler>(); // runs first
builder.Services.AddExceptionHandler<FallbackExceptionHandler>(); // runs if first returns false
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
// Process-level last chance
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
app.Logger.LogCritical(e.ExceptionObject as Exception, "Unhandled exception, terminating");
TaskScheduler.UnobservedTaskException += (_, e) =>
{
app.Logger.LogError(e.Exception, "Unobserved task exception");
e.SetObserved();
};
app.Run();
public sealed class DomainExceptionHandler(IProblemDetailsService problems, ILogger<DomainExceptionHandler> log)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception ex, CancellationToken ct)
{
if (ex is not DomainException domain) return false;
log.LogWarning(ex, "Domain rule {Code} violated", domain.Code);
http.Response.StatusCode = StatusCodes.Status409Conflict;
return await problems.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = http,
Exception = ex,
ProblemDetails = { Title = "Request conflicts with current state", Type = domain.Code }
});
}
}
public sealed class FallbackExceptionHandler(ILogger<FallbackExceptionHandler> log) : IExceptionHandler
{
public ValueTask<bool> TryHandleAsync(HttpContext http, Exception ex, CancellationToken ct)
{
log.LogError(ex, "Unhandled exception for {Path}", http.Request.Path);
return ValueTask.FromResult(false); // let the default write a generic 500 ProblemDetails
}
}
// Worker: catch per item, let fatal errors stop the host
public sealed class OrderWorker(IQueue queue, ILogger<OrderWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var msg in queue.ReadAllAsync(ct))
{
try { await HandleAsync(msg, ct); }
catch (Exception ex) when (ex is not OperationCanceledException)
{
log.LogError(ex, "Message {Id} failed; dead-lettering", msg.Id);
await queue.DeadLetterAsync(msg, ct);
}
}
}
}
What happens: each action catches Exception, logs it and returns BadRequest(ex.Message) or StatusCode(500).
Why it is wrong: duplicated code, inconsistent responses, leaked messages and wrong status codes.
Do instead: use UseExceptionHandler with IExceptionHandlers and ProblemDetails.
What happens: detail = ex.ToString() or the developer exception page in production.
Why it is wrong: leaks stack traces, SQL, internal paths and possibly personal data (OWASP information disclosure).
Do instead: generic messages with a traceId; full details only in logs.
UnhandledException to keep the process aliveWhat happens: expecting the event handler to "handle" errors. Why it is wrong: it's a notification — the process terminates afterwards. Do instead: log and flush telemetry there; prevent unhandled exceptions at their sources.
async void methodsWhat happens: async void SendEmail() called from services.
Why it is wrong: exceptions can't be caught by callers and crash the process.
Do instead: return Task; use async void only for event handlers with internal try/catch.
What happens: _ = _emailService.SendAsync(...) in a controller.
Why it is wrong: exceptions are lost; scoped services (DbContext) may be disposed when the request ends.
Do instead: enqueue work to a background queue or durable message broker.
What happens: one malformed message throws out of ExecuteAsync.
Why it is wrong: stops the host (or, pre-.NET 6, silently kills the worker), halting all processing.
Do instead: catch per item, log, dead-letter, and continue; let truly fatal errors stop the host.
What happens: client disconnects (OperationCanceledException) logged as errors and counted as 500s.
Why it is wrong: noisy alerts for normal behaviour.
Do instead: filter cancellation when HttpContext.RequestAborted is cancelled; log at Debug/Information.
What happens: repository, service, handler and middleware all log it. Why it is wrong: inflated error counts and confusing investigations. Do instead: log once at the boundary; add context via scopes and tracing.
What happens: different endpoints return different error JSON shapes.
Why it is wrong: clients can't handle errors generically.
Do instead: standardise on ProblemDetails with stable type URIs.
UnobservedTaskExceptionWhat happens: no subscription; fire-and-forget failures vanish.
Why it is wrong: silent failures in production.
Do instead: subscribe, log and call SetObserved(), and remove the fire-and-forget code it reveals.
Seniors design error handling as a cross-cutting architecture concern: one boundary per entry point, one log per failure, one response format.
| Entry point | Boundary | Outcome |
|---|---|---|
| HTTP | IExceptionHandler chain |
ProblemDetails + traceId |
| Message consumer | per-message catch | retry / dead-letter |
| Background loop | per-iteration catch | continue; fatal -> stop host |
| Process | UnhandledException |
log, flush, crash, restart |
| Fire-and-forget | queue or continuation | never unobserved |
They also link this to observability: the traceId in the response matches the trace in logs and distributed tracing, so support can find the failure from a customer's screenshot. And they treat a crash-restart as a valid strategy for corrupted state, rather than catching everything to stay up.
Create a custom exception only when callers need to catch it specifically or need extra data that built-in exceptions do not carry. Otherwise use the standard ones: ArgumentException family for bad input, InvalidOperationException for wrong state, NotSupportedException, KeyNotFoundException, TimeoutException. A good custom exception derives from Exception (or a more specific built-in type), not ApplicationException, ends in Exception, provides the three standard constructors (none, message, message plus inner), carries domain properties such as OrderId, and is usually sealed. Do not add the old binary-serialisation constructor: that API is obsolete in modern .NET (SYSLIB0051). For expected business outcomes, a result type is often better than any exception.
The code shows the classic problems: throw ex destroys the stack trace; catching Exception and returning null swallows real failures; logging ex.Message loses the chain; catch-log-rethrow at every layer duplicates logs; .Result blocks and wraps errors in AggregateException; async void crashes the process; throwing Exception gives callers nothing to catch; clean-up outside finally leaks the lock; exceptions used for control flow (parse in try/catch); and cancellation logged as an error. The rewrite uses async Task all the way, using/finally for clean-up, Try methods for expected failures, a specific domain exception with an inner exception, filters for transient retries, and one global handler that logs once.
How to tell it (STAR)
**Situation:** A penetration test at [company] found our API returning stack traces in some error responses, and our frontend had special cases for [five] different error formats. **Task:** I led the fix, aiming for one consistent and safe error format across the API. **Action:** I removed the try/catch blocks from controllers, introduced `IExceptionHandler`s that map domain, validation and cancellation exceptions to the right status codes, and a fallback that logs the full exception and returns a generic 500 `ProblemDetails` with a trace ID. I added architecture and integration tests so the old pattern could not come back. **Result:** The re-test passed, the frontend deleted its special cases, duplicate logs disappeared, and support could find any failure from the trace ID a customer reported.
Last updated 30 Oct 2026.