Exception handling looks simple until you have to explain why it behaves the way it does under load, across async boundaries, and at the edge of a public API. Interviewers use this topic to separate developers who memorized try/catch/finally syntax from engineers who understand the CLR's two-pass unwind model, the real cost of throwing, and when an exception is the wrong tool entirely. For engineers with a decade or more of production experience, these questions probe judgment: where do you let an exception propagate, where do you convert it to data, and how do you keep a system diagnosable when something genuinely goes wrong at 3 a.m.? The ten questions below cover cost, mechanics, modern ASP.NET Core patterns and the trade-offs a senior engineer is expected to reason about out loud.
Q1 Why are exceptions expensive in .NET, and when does that cost actually matter?#
Short answer: Throwing an exception is expensive relative to a normal return because the CLR has to walk the stack, run the two-pass search-and-unwind protocol, capture stack trace information and potentially trigger JIT work for cold catch handlers; that cost is irrelevant for genuine error paths but ruinous if you use exceptions for routine control flow.
The dominant costs are the stack walk itself (locating a handler frame by frame, consulting each method's exception handling tables), capturing the stack trace text lazily the first time it's observed, and, for the first throw of a given exception type in a process, JIT-compiling the catch handler and any first-chance-exception machinery around it. None of this is proportional to "how bad" the error is — throwing to signal "item not found in a loop that runs a million times" costs orders of magnitude more than returning false or default, and it also defeats a lot of JIT optimizations around the try region, because the compiler must keep more state observable in case a handler needs it. In steady state, a hot path that throws on a common, expected condition (a cache miss, a parse failure on user input, a "no rows" result) will show up immediately in a profiler as GC pressure from stack trace strings plus raw CPU time in exception dispatch.
This does not mean "avoid exceptions." It means: reserve them for conditions that are actually exceptional — a failed network call, a violated invariant, a bug — and use return values, TryParse- style APIs, nullable results or a Result type for expected, frequent "failure" outcomes such as validation errors or business-rule rejections.
What interviewers look for: whether you can articulate why the cost exists (stack walking and unwinding, not just "it's slow because I read that somewhere"), and whether you naturally reach for non-exceptional control flow for high-frequency expected outcomes without over-correcting into never throwing at all.
Common mistakes: blaming try/catch blocks themselves for the cost (an untriggered try block is essentially free on the CLR); assuming all exceptions are equally expensive regardless of frequency or depth of the call stack being unwound.
Q2 Walk through the two-pass exception handling model in the CLR. What does each pass do?#
Short answer: When an exception is thrown, the CLR first performs a search pass that walks the stack looking for a handler that will accept the exception — running any catch filters along the way — without unwinding anything; only once a handler is found does it run the unwind pass, which walks the same frames again, running finally blocks and actually popping them off the stack.
This two-pass design (inherited from Windows structured exception handling, which CoreCLR builds on) matters for two concrete reasons. First, filters (catch (Exception ex) when (condition)) execute during the first pass, before anything is unwound. That means a debugger attached at the moment of a first-chance exception can inspect the full original stack, including frames that would otherwise already be gone by the time a plain catch block runs — this is why "break on first chance exception with a conditional filter" is such a useful debugging technique, and why exception filters are more than syntactic sugar for catch { if (...) throw; }. Second, because the search pass doesn't unwind anything, a handler further up the stack can decide not to handle the exception (the filter returns false) and the runtime keeps walking outward, all without disturbing frames it ultimately won't touch if no handler ever accepts it — which is also why an unhandled exception can still show you the original, undisturbed stack in a crash dump.
In practice, this means the moment you see "first chance exception" in your debugger output, nothing has unwound yet — a finally block below that point has not run. Only after a matching handler is located does the second pass begin popping frames and executing finally blocks on the way out.
What interviewers look for: an accurate mental model that filters run before unwinding, that finally runs during the second pass, and the ability to connect this to observable behavior (crash dump fidelity, filter-based debugging, why filters can have side effects you didn't expect).
Follow-up questions:
- Why can a
catchfilter with side effects (like logging) run even when the exception is ultimately rethrown or handled elsewhere? - How does this model interact with
finallyblocks that themselves throw during unwind?
Q3 What are exception filters, and why use one instead of catching and rethrowing?#
Short answer: An exception filter (catch (T ex) when (predicate)) lets you conditionally select a handler based on a boolean expression evaluated during the CLR's first pass, before any unwinding, which is both cheaper and more correct than catching broadly and calling throw; to reject cases you don't want.
try
{
await httpClient.GetAsync(uri, cancellationToken);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.TooManyRequests)
{
await DelayForRetryAfterAsync(ex, cancellationToken);
}
catch (HttpRequestException ex) when (LogAndReturnFalse(ex))
{
// never reached: LogAndReturnFalse always returns false, so this filter never matches;
// it exists purely to log every HttpRequestException that passes through this method.
}The filter pattern has two advantages over catch { if (!condition) throw; }. First, it's precise: you only enter the handler body for cases you actually intend to handle, so you can have several narrow, readable handlers for the same exception type instead of one handler with nested conditionals. Second, because the filter runs during the search pass, rejecting a case (the filter returns false) leaves the stack completely undisturbed — the runtime keeps searching outward as if your catch block weren't there, which preserves the original stack trace and crash dump fidelity. Compare that to catching and rethrowing: by the time you call throw; inside the body, the stack has already unwound down to that frame, and a debugger or crash reporter that captures state at that point sees a different, shorter picture of what happened.
Filters are also popular as a logging technique: a filter that always returns false after logging lets you observe every exception of a given type flowing past a frame, without ever actually handling it, using no extra try/catch nesting.
What interviewers look for: correct mechanics (filters run pre-unwind), a working code example, and awareness of the logging-filter idiom, which is a strong signal of real production experience.
Common mistakes: writing filters with expensive or non-deterministic side effects, forgetting that a filter that throws is treated as "false" (the search continues), and assuming filters are purely syntactic sugar with no runtime-behavior difference from catch-then-if.
Q4 What's the difference between throw; and throw ex;, and why does it matter?#
Short answer: throw; rethrows the current exception and preserves its original stack trace and watson/diagnostic metadata, while throw ex; throws the same exception object but resets its stack trace to start at the throw ex; line, destroying the information about where the exception actually originated.
try
{
ParseOrder(payload);
}
catch (FormatException ex)
{
_logger.LogWarning(ex, "Malformed order payload");
throw; // preserves the original stack trace pointing into ParseOrder
// throw ex; // would instead show this catch block as the throw site
}Both statements throw the same Exception instance — throw ex; does not create a copy — but the CLR stamps stack trace information onto the exception object as it propagates, and throw ex; overwrites what was already recorded, making it look as if the exception originated at the rethrow point instead of deep inside ParseOrder. In a small demo this is a curiosity; in a production incident, it's the difference between a stack trace that takes you straight to the bug and one that sends you on a wild goose chase through a generic error-handling layer.
The one legitimate reason to reconstruct a stack trace intentionally is when you're crossing a meaningful boundary and want the new frame to be the "interesting" one — for example wrapping a low-level exception in a domain-specific one (throw new OrderProcessingException("...", ex);), where the inner exception is preserved via the InnerException chain rather than lost.
What interviewers look for: the precise mechanism (stack trace metadata, not stack trace text alone), a code example showing correct usage, and recognition that this is a code-review-level habit, not just interview trivia — candidates who've been burned by throw ex; in a log usually say so.
Common mistakes: believing throw ex; creates a new exception object; using throw ex; inside generic middleware or filters "to be explicit," which quietly destroys the most useful diagnostic information available at incident time.
Q5 Explain ExceptionDispatchInfo. When would you use Capture(ex).Throw() instead of throw;?#
Short answer: ExceptionDispatchInfo.Capture(ex) snapshots an exception together with the stack trace and context it had at the point of capture, so that calling .Throw() later — potentially from a different method, thread or point in time — rethrows it with that original information intact, something a plain throw ex; cannot do and a bare throw; cannot do outside the original catch block at all.
private static Exception? _capturedFailure;
async Task RunWorkerAsync(CancellationToken ct)
{
try
{
await DoWorkAsync(ct);
}
catch (Exception ex)
{
ExceptionDispatchInfo.Capture(ex).Throw();
throw; // unreachable, but keeps the compiler happy about definite assignment
}
}The classic use case is deferred rethrow: you catch an exception in one place (say, inside a Task.Run continuation, a producer/consumer queue, or Parallel.ForEach's aggregation), store it, and need to surface it later from a completely different call frame — often after collecting several failures. throw; only works inside the exact catch block where the exception was caught, so it's not an option once you've stored the exception and moved on. ExceptionDispatchInfo solves that: .Throw() rethrows the original exception object with its original stack trace appended to, not replaced by, the new throw site, which is strictly better diagnostic information than throw ex; would give you.
It's worth knowing that the TPL uses this exact mechanism internally: when you await a faulted Task, the awaiter uses ExceptionDispatchInfo to rethrow the task's exception (unwrapped from AggregateException) with its original stack trace preserved, which is why await-ing a task gives you a much more useful stack trace than calling .Result and catching the resulting AggregateException yourself.
What interviewers look for: knowing this API exists and why it's different from both throw; and throw ex;, plus the connection to how await itself rethrows task exceptions — that detail signals genuine familiarity with the TPL's internals, not just rote API recall.
Follow-up questions:
- Why does
await-ing a faulted task give a cleaner exception than readingTask.Exceptiondoes? - How would you propagate the first exception from several tasks run with
Task.WhenAll?
Q6 How do you implement centralized exception handling in ASP.NET Core with IExceptionHandler?#
Short answer: You implement IExceptionHandler's TryHandleAsync(HttpContext, Exception, CancellationToken) method, register it with AddExceptionHandler<T>(), add the exception-handling middleware with UseExceptionHandler(), and return true once you've written a response — the middleware calls registered handlers in registration order and stops at the first one that returns true.
public sealed class ValidationExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
if (exception is not ValidationException validationEx)
{
return false; // let the next registered handler (or the fallback) take it
}
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(
new { title = "Validation failed", errors = validationEx.Errors },
cancellationToken);
return true;
}
}
// Program.cs
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
builder.Services.AddExceptionHandler<DefaultExceptionHandler>(); // catch-all, registered last
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();This replaces the older pattern of app.UseExceptionHandler("/error") plus an MVC error controller, or a hand-rolled try/catch middleware. IExceptionHandler gives you testable, DI-friendly, composable handlers — register one per exception category (validation, not-found, concurrency conflict, a catch-all) instead of one giant switch on exception type. It composes naturally with IProblemDetailsService: a handler can call problemDetailsService.WriteAsync(...) to produce a standard RFC 7807 problem response instead of hand-writing JSON. As of .NET 10, a handled exception no longer automatically feeds the diagnostics pipeline the way an unhandled one does by default, so log explicitly inside the handler rather than assuming the platform will do it for you.
What interviewers look for: the registration-order-matters detail, the false-to-fall-through convention, and awareness that this is the modern replacement for ad hoc exception middleware — bonus points for mentioning IProblemDetailsService and RFC 7807.
Common mistakes: writing a single handler that tries to deal with every exception type via if/ else if chains instead of several focused handlers; forgetting UseExceptionHandler() (registered handlers are never invoked without the middleware); assuming the presence of a handler still means the exception won't reach your logging framework by default in newer versions.
Q7 When would you use a Result/error-as-value pattern instead of exceptions?#
Short answer: Use a Result-style return type for outcomes that are frequent, expected and part of normal business logic — validation failures, "not found," business-rule rejections — and reserve exceptions for conditions that are genuinely exceptional (infrastructure failures, bugs, broken invariants); the trade-off is explicitness and performance versus the convenience of exceptions automatically propagating through call stacks.
public readonly record struct Result<TValue, TError>(TValue? Value, TError? Error, bool IsSuccess)
{
public static Result<TValue, TError> Success(TValue value) => new(value, default, true);
public static Result<TValue, TError> Failure(TError error) => new(default, error, false);
}
public Result<Order, OrderError> PlaceOrder(OrderRequest request)
{
if (!_catalog.HasStock(request.Sku, request.Quantity))
{
return Result<Order, OrderError>.Failure(OrderError.InsufficientStock);
}
return Result<Order, OrderError>.Success(CreateOrder(request));
}The case for Result types: the type signature documents every failure mode, the compiler forces callers to at least look at IsSuccess before touching Value, there's no hidden non-local control flow, and you avoid the CLR's exception-throwing cost entirely on paths that fire constantly (order placement failing due to stock is not exceptional — it's Tuesday). Libraries such as FluentResults and ErrorOr implement variations on this idea, usually paired with pattern matching to force exhaustive handling of both outcomes.
The case against: it doesn't compose automatically the way exceptions do — every layer in a call chain has to explicitly check and forward the Result, which can turn deep call stacks into repetitive plumbing unless you lean on functional composition helpers (Bind, Map). It also doesn't fit constructors, operators or interface methods defined by frameworks that expect either a value or a thrown exception (LINQ, most of the BCL). The pragmatic answer senior engineers give is a hybrid: use exceptions for truly exceptional, unrecoverable, or "this is a bug" conditions and for anything that crosses a boundary you don't control (EF Core, HTTP clients), and use Result types at your own application's public seams for expected, recoverable business outcomes.
What interviewers look for: a balanced answer, not dogma in either direction — you should be able to say precisely which kinds of failures belong in which bucket and defend the boundary.
Q8 What happens to an unobserved exception on a Task? How do you detect these in production?#
Short answer: If a Task faults and nothing ever observes its exception — no await, no .Result/.Wait(), no check of .Exception — the exception is only escalated once the Task object is garbage collected, via its finalizer, at which point the CLR raises the TaskScheduler.UnobservedTaskException event rather than crashing the process outright, which is the default behavior modern .NET has used since it moved away from .NET Framework 4.0's original crash-by-default policy.
That's the trap: an unobserved exception doesn't surface immediately. It can sit silently for seconds or minutes until the GC collects the faulted Task, which makes the failure feel random and hard to reproduce — a background operation quietly failed, nothing logged it, and the only evidence is a GC- triggered event firing much later, if you even have a handler wired up.
TaskScheduler.UnobservedTaskException += (sender, e) =>
{
_logger.LogError(e.Exception, "Unobserved task exception");
e.SetObserved(); // prevents further escalation
};Wiring that handler is a safety net, not a fix — the real fix is never firing off a Task you don't await or otherwise observe. Patterns that create unobserved tasks: _ = SomeAsyncMethod(); with no error handling inside the method, Task.Run results that are discarded, background work started from a constructor, and fire-and-forget event handlers. If you genuinely need fire-and-forget semantics (for example, a best-effort metrics push), wrap the body in its own try/catch that logs, so the task can never fault unobserved in the first place, or use a proper background-task abstraction such as IHostedService/BackgroundService with a supervised loop instead of ad hoc discarded tasks.
What interviewers look for: accurate knowledge that this doesn't crash the process by default in modern .NET (a common misconception carried over from older .NET Framework folklore), and a concrete list of code patterns that create unobserved tasks along with how to avoid them structurally.
Follow-up questions:
- How would
Task.WhenAllbehave if two of five tasks fault — what happens to the other exceptions? - Why is discarding a
Taskwith_ =different from truly fire-and-forget background work?
Q9 What is fail-fast, and when should code call Environment.FailFast instead of throwing?#
Short answer: Fail-fast means terminating the process immediately, without running finally blocks, exception filters or any further managed code, because continuing to run risks operating on corrupted state; Environment.FailFast(message, exception) is the explicit API for that, and it's for "this process can no longer be trusted," not for ordinary error handling.
The useful distinction is this: an ordinary unhandled exception still unwinds the stack (running finally blocks along the way) before the runtime terminates the process, which is appropriate when the failure is contained even if severe. Environment.FailFast skips all of that — no unwind, no finally, no exception dispatch handlers — and writes directly to the Windows Application event log (or standard error on other platforms) along with a crash dump if configured, then exits.
if (_heapConsistencyCheckFailed)
{
Environment.FailFast(
"Corrupted internal cache detected; refusing to continue.",
new InvalidOperationException("Cache invariant violated"));
}You reach for this when continuing execution is actively dangerous: a detected memory corruption, an invariant that, if violated, means every subsequent operation could silently produce wrong results (financial calculations, safety-critical state machines), or a finally block that would otherwise run cleanup logic against data you now know is untrustworthy. It's also what the CLR itself does internally for genuinely unrecoverable conditions, such as a corrupted GC heap or a failure inside a critical finalizer. In ordinary application code this should be rare — most failures, even serious ones, are better served by logging, alerting and a clean shutdown (or restart via your orchestrator) than by a hard, unwind-skipping crash. Reach for it deliberately, document why, and expect your platform's process supervisor (Kubernetes, systemd, IIS) to restart the process afterward.
What interviewers look for: the key distinction that fail-fast skips unwinding entirely (not just "it's a more severe exception"), and judgment about how rare a legitimate use case actually is — candidates who reach for it too eagerly are showing inexperience with production incident response.
Q10 Design an exception-handling strategy for a layered app: web API, domain, infrastructure.#
Short answer: Let exceptions propagate freely within a layer and across layers you trust, catch only at boundaries where you must translate one failure model into another — infrastructure exceptions into domain exceptions, domain exceptions into HTTP responses — and never catch broadly just to log and rethrow unchanged, since that adds cost and noise without adding information.
A pattern that scales well on real systems:
- Infrastructure layer (database, HTTP clients, message brokers): let library-specific exceptions (
DbUpdateException,HttpRequestException) propagate up mostly unchanged, but wrap them in a domain-meaningful exception at the repository or gateway boundary when the caller shouldn't need to know about EF Core orHttpClient— for example, catchingDbUpdateExceptionfor a unique- constraint violation and rethrowing asDuplicateOrderException, with the original asInnerException. - Domain layer: throw specific, named exceptions for genuine invariant violations (
InsufficientStockException,InvalidOrderStateException); for expected, frequent "no" outcomes that are part of normal business flow, prefer a Result type over exceptions so the domain's success path stays cheap and the failure modes are visible in method signatures. - API layer: a small set of
IExceptionHandlerimplementations, one per exception family, map domain exceptions to the correct HTTP status codes and problem-details payloads; this is the single place that knows about HTTP, so domain and infrastructure code stay free ofHttpContextor status codes entirely.
public sealed class DomainExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext ctx, Exception exception, CancellationToken ct)
{
var (status, title) = exception switch
{
InsufficientStockException => (StatusCodes.Status409Conflict, "Insufficient stock"),
InvalidOrderStateException => (StatusCodes.Status422UnprocessableEntity, "Invalid state"),
_ => (0, null as string),
};
if (status == 0) return false;
ctx.Response.StatusCode = status;
await ctx.Response.WriteAsJsonAsync(new { title }, ct);
return true;
}
}The rule that keeps this maintainable under pressure: every catch block should either add information (translate to a more meaningful exception, attach context) or terminate propagation with a concrete response — never both nothing and nowhere, which is what "catch, log, swallow" produces.
What interviewers look for: a coherent boundary-based strategy rather than a rule like "always catch at the top level," explicit reasoning about where exceptions get translated and why, and recognition that not every failure belongs in the exception channel at all.
Common mistakes: a single global try/catch around Main or the whole request pipeline treated as the entire strategy; catching exceptions in the domain layer purely to log them and rethrow unchanged, which adds stack frames and cost for zero benefit.
Quick-Fire Round#
| Question | Answer |
|---|---|
Does an untriggered try block cost anything at runtime? | Essentially no; the cost is in throwing, not in the block existing. |
| Which pass runs exception filters, search or unwind? | The search (first) pass, before anything unwinds. |
Does throw ex; create a new exception object? | No, same object; it resets the recorded stack trace. |
| Does an unobserved task exception crash the process by default today? | No; it raises TaskScheduler.UnobservedTaskException instead. |
| What HTTP-layer interface replaces ad hoc exception middleware in ASP.NET Core? | IExceptionHandler, registered with AddExceptionHandler<T>(). |
Does Environment.FailFast run finally blocks? | No; it terminates immediately, skipping unwind entirely. |
| Is a Result type a replacement for all exceptions? | No; it's for expected, frequent outcomes, not for bugs or infrastructure failures. |
| What preserves the original stack trace across an arbitrary rethrow point? | ExceptionDispatchInfo.Capture(ex).Throw(). |
How to Prepare#
- Practice explaining the two-pass model with a concrete example tied to a debugging technique, not just the phase names.
- Build a small minimal API with two or three
IExceptionHandlerimplementations and verify the registration-order and fallthrough behavior yourself. - Trigger an unobserved task exception on purpose and watch when
UnobservedTaskExceptionactually fires relative to the fault — the delay surprises most people. - Prepare a concrete, defensible rule for when your team uses exceptions versus a Result type; vague "it depends" answers underperform a clear boundary.
- Review
ExceptionDispatchInfoagainstthrow;until you can explain the difference without notes.