📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

๐Ÿ“š Interview question

How expensive are exceptions in .NET? What exactly costs time when you throw and catch, what changed in .NET 9, how do try blocks affect performance when nothing is thrown, and what patterns (Try methods, result types, throw helpers) avoid exceptions on hot paths?

The real cost of exceptions in modern .NET, how to measure exception rates, and the patterns that keep them off hot paths.

Senior Theory Advanced diagnostics exceptions performance result-type try-pattern

๐ŸŽฏ Key points

  • Entering a `try` block costs nothing at run time; throwing is what is expensive
  • Throw cost comes from allocation, stack trace capture, two-pass search and unwind, and logging
  • A throw costs microseconds versus nanoseconds for a normal return, more with deep stacks
  • .NET 9 introduced managed exception handling that is roughly 2-4x faster than before
  • Use Try methods and result types for expected, frequent failures
  • Throw helpers keep the throwing code out of hot methods so they stay inlinable
  • Keep throwing for bugs and infrastructure failures; do not replace every exception with results
  • Measure exception rates with `dotnet-counters` or telemetry before optimising

โฑ 30-second answer

A try block costs almost nothing when no exception is thrown; .NET uses table-based handling, so there is no work on the happy path (though a method containing try/catch may get fewer JIT optimisations such as inlining). Throwing is expensive: allocating the exception, capturing the stack trace, and the two-pass search and unwind cost microseconds, versus nanoseconds for a normal return, and more with deep stacks. .NET 9 replaced the C++-based exception handling with a managed implementation (from NativeAOT), making throw and catch roughly 2 to 4 times faster, but it is still far slower than returning a value. So keep exceptions for exceptional cases, and on hot paths with expected failures use Try methods (int.TryParse, TryGetValue), result types, and throw helpers that keep the throwing code out of line. Measure exception rates with dotnet-counters before optimising.

๐Ÿ—ฃ Full answer (senior)

How I would answer this in an interview

Exceptions are cheap when nothing is thrown and expensive when something is. The cost is in creating the exception object, capturing the stack trace, and the runtime's two-pass search and unwind. .NET 9 made throwing and catching several times faster by moving exception handling to a managed implementation, but a throw is still orders of magnitude slower than a normal return. So the rule stays: exceptions for exceptional conditions; Try methods, result types and validation for expected failures on hot paths.

1. What costs time when you throw and catch

  1. Allocating the exception object (plus its message string, often formatted).
  2. Capturing the stack trace: the runtime walks frames and records method/IP pairs as the exception propagates; deeper stacks cost more. Formatting StackTrace to text (with PDB line lookups) is even more expensive, but happens only if you read it or log it.
  3. First pass: searching every frame up the stack for a matching handler, evaluating filters.
  4. Second pass: unwinding frames, running finally blocks, restoring registers, then running the catch.
  5. Side effects: debugger/diagnostic notifications (first-chance exception events, ETW/EventPipe events, Application Insights collection), and cold code paths that miss CPU caches.
  6. Async: each await layer rethrows via ExceptionDispatchInfo, so an exception crossing five async methods is effectively thrown five times.

Rough orders of magnitude (vary by hardware and stack depth): a method returning a failure code takes nanoseconds; a throw/catch across a few frames took ~10โ€“50 ยตs on .NET 8 and around 2โ€“4ร— less on .NET 9+. Across async layers, multiply by the number of awaits. At 10,000 failures per second, that's real CPU.

2. What changed in .NET 9

.NET 9 replaced the CoreCLR's C++/SEH-based exception handling with the managed exception handling implementation from NativeAOT (enabled by default; DOTNET_LegacyExceptionHandling=1 reverts). Benchmarks from the runtime team showed throw/catch 2โ€“4ร— faster in many scenarios. It's a big improvement, but not a reason to use exceptions for control flow โ€” the relative gap to a plain return is still enormous, and allocation and logging costs remain.

3. Do try blocks cost anything when nothing is thrown?

Almost nothing. .NET uses table-based (zero-cost) exception handling: a try region is metadata describing IL ranges and handlers; no instructions run on entry or exit. Minor second-order effects:

  • Methods with exception handling are less likely to be inlined (historically never; modern JITs can inline some methods with try/finally, and .NET 9+ improved this).
  • The JIT may keep some variables in memory rather than registers across try boundaries.
  • finally blocks are typically cloned into the normal path ("finally cloning"), so they're cheap.

So you shouldn't avoid try/using for performance โ€” just avoid throwing in hot, expected paths.

4. Patterns that avoid exceptions on hot paths

Try methods (the BCL pattern):

if (!int.TryParse(input, NumberStyles.Integer, CultureInfo.InvariantCulture, out var qty)) return Invalid("quantity");
if (!_cache.TryGetValue(key, out var value)) value = await LoadAsync(key, ct);
if (!_queue.TryDequeue(out var item)) return;

Result types for business outcomes:

public Result<Order, OrderError> Ship(Order order) =>
    order.Status switch
    {
        OrderStatus.Paid    => Result.Ok(order with { Status = OrderStatus.Shipped }),
        OrderStatus.Shipped => Result.Fail(OrderError.AlreadyShipped),
        _                   => Result.Fail(OrderError.NotPaid)
    };

Validation up front: FluentValidation / data annotations / minimal API validation returning ValidationProblemDetails instead of throwing per field.

Throw helpers keep the non-throwing path fast: guard checks inline, and the throw lives in a separate [DoesNotReturn] method (ArgumentNullException.ThrowIfNull, ArgumentOutOfRangeException.ThrowIfNegative). This doesn't make throwing cheaper; it makes not throwing cheaper by keeping hot methods small and inlinable.

Avoid exception-driven patterns like try { dict[key] } catch (KeyNotFoundException), catch (FormatException) around parsing, or using exceptions to exit loops.

5. How I measure it in production

  • dotnet-counters monitor System.Runtime: exception-count (exceptions thrown per interval). A healthy API usually throws very few; hundreds or thousands per second indicates exceptions in normal flow.
  • Application Insights / OpenTelemetry: exception telemetry grouped by type and operation.
  • dotnet-trace with the exceptions keyword, or PerfView, to see where first-chance exceptions are thrown โ€” including ones that are caught and hidden.
  • BenchmarkDotNet for micro-measurements of a specific path.

6. A production example

A product-search API validated query parameters by calling int.Parse/Enum.Parse inside try/catch and returned 400 on FormatException. A bot started sending malformed queries at a few thousand requests per second; dotnet-counters showed ~4,000 exceptions/second, CPU at 90%, and Application Insights exception ingestion costs spiked. Replacing the parsing with TryParse and validation that returned ValidationProblemDetails, plus rate limiting, dropped exceptions to near zero and CPU to normal. The bot traffic became cheap to reject.

๐Ÿ” What the interviewer is checking

Checks understanding of runtime costs and pragmatic error design.

  • Green flags: knows try is free and throw is expensive, lists what the cost is made of, mentions .NET 9 managed exception handling, uses Try methods, result types and throw helpers, and measures exception rates.
  • Red flags: says exceptions are "always slow" or "free", avoids try/finally for performance, or replaces all exceptions with error codes.

๐Ÿ“š Learn in depth

1. Anatomy of a throw in CoreCLR

throw new InvalidOperationException("...")
  1. newobj      -> allocate exception, run ctor (message formatting)
  2. throw       -> RaiseException path (since .NET 9: managed EH in System.Private.CoreLib)
  3. Pass 1      -> stack walk using unwind info; for each frame, consult EH table; run filters
  4. Pass 2      -> unwind to handler frame; run finally/fault funclets; record stack trace frames
  5. catch body  -> runs as a funclet with access to the parent frame

The stack walk uses unwind information generated by the JIT for each method (how to restore registers and find the caller frame). Each frame visited costs a lookup. Deep call stacks โ€” typical with middleware pipelines, DI-heavy code and async โ€” make exceptions more expensive.

Funclets: the JIT compiles catch, finally and filter blocks as separate small functions (funclets) called by the runtime during dispatch.

2. Why .NET 9's managed EH is faster

The old implementation went through the OS's structured exception handling (on Windows) or a C++ emulation (on Unix), with multiple transitions between native and managed code, locks and complex bookkeeping. NativeAOT already had a leaner managed implementation written in C#; .NET 9 adopted it for CoreCLR. Fewer transitions and simpler stack walking reduced throw/catch costs significantly. Semantics (two-pass, filters, finally ordering) are unchanged.

3. Stack trace capture costs

  • Recording frames during unwind is relatively cheap (method handles + offsets).
  • Formatting ex.StackTrace or ex.ToString() is expensive: resolving method names, generic arguments, and file/line numbers from PDBs. Logging every exception with full ToString() at high rates can dominate the cost.
  • Reusing a pre-allocated exception instance (throwing the same object repeatedly) doesn't make it cheap โ€” the stack is still captured โ€” and it corrupts stack traces. Don't do it.

4. Async amplification

Controller -> Service -> Repository -> HttpClient
    await        await        await

If HttpClient throws, the exception is stored in the innermost task; each await up the chain calls ExceptionDispatchInfo.Throw, which performs a new throw in that frame (with the preserved trace appended). Three awaits โ‰ˆ three throw/catch cycles. Async-heavy failure paths are therefore more expensive than synchronous ones โ€” another reason to avoid exceptions for expected outcomes in async APIs.

5. Try pattern design

Good Try methods:

  • Return bool and use out for the result, annotated with [NotNullWhen(true)] / [MaybeNullWhen(false)] for nullable analysis.
  • Never throw for the expected failure they represent (bad format, missing key) โ€” but still throw for programming errors (null arguments).
  • Come with a throwing counterpart (Parse/TryParse) for callers who treat failure as exceptional.
  • For async, out isn't available; return a tuple, nullable, or result type: Task<(bool Found, Order? Order)> or Task<Order?>.

6. Result type performance

A readonly struct result (Result<T, TError>) costs a few bytes and no allocation; propagation is a branch. Libraries vary: some allocate error objects or use classes. For hot paths, prefer struct results and pre-allocated static error instances (static readonly Error NotFound = new(...)).

7. Measuring with BenchmarkDotNet

[MemoryDiagnoser]
public class ParseBench
{
    private const string Bad = "12x";

    [Benchmark] public int TryParse() => int.TryParse(Bad, out var v) ? v : -1;

    [Benchmark] public int ParseCatch()
    {
        try { return int.Parse(Bad); }
        catch (FormatException) { return -1; }
    }
}

Expect TryParse in nanoseconds with zero allocations, and ParseCatch in microseconds with hundreds of bytes allocated (exception object, message, stack trace data). Run on .NET 8 and .NET 9+ to see the managed EH improvement.

Try it yourself

  1. Run the benchmark above on .NET 8 and .NET 10.
  2. Measure throw/catch cost at stack depths of 1, 10 and 50 frames, and across 5 nested awaits.
  3. Watch exception-count in dotnet-counters while sending invalid input to an endpoint that uses Parse vs TryParse.
  4. Use DOTNET_LegacyExceptionHandling=1 on .NET 9+ to compare old and new EH performance.

๐Ÿ’ป Code

using System.Diagnostics.CodeAnalysis;

// Bad: exceptions as control flow on a hot path
decimal ParseSlow(string s)
{
    try { return decimal.Parse(s, CultureInfo.InvariantCulture); }
    catch (FormatException) { return 0m; }            // thrown for every blank field
}

// Good: Try pattern
decimal ParseFast(string s) =>
    decimal.TryParse(s, NumberStyles.Number, CultureInfo.InvariantCulture, out var d) ? d : 0m;

// Result type for business outcomes
public readonly record struct Result<T>(T? Value, string? Error)
{
    public bool IsSuccess => Error is null;
    public static Result<T> Ok(T value) => new(value, null);
    public static Result<T> Fail(string error) => new(default, error);
}

public Result<Payment> Withdraw(Account acc, decimal amount) =>
    acc.Balance < amount
        ? Result<Payment>.Fail("insufficient-funds")      // expected outcome, no throw
        : Result<Payment>.Ok(acc.Debit(amount));

// Throw helper keeps the hot method small
public sealed class RingBuffer<T>(int capacity)
{
    private readonly T[] _items = new T[capacity];
    private int _count;

    public T this[int index]
    {
        get
        {
            if ((uint)index >= (uint)_count) ThrowIndex(index);
            return _items[index];
        }
    }

    [DoesNotReturn]
    private static void ThrowIndex(int index) =>
        throw new ArgumentOutOfRangeException(nameof(index), index, "Index outside the buffer.");
}

โš  Common mistakes

1. Using exceptions for expected control flow

What happens: parsing, lookups, validation and "not found" handled with throw/catch. Why it is wrong: microseconds per failure, allocations, noisy telemetry โ€” and an easy DoS vector for invalid input. Do instead: Try methods, validation and result types.

2. Avoiding try/using for performance

What happens: developers remove using blocks or try/finally believing they're slow. Why it is wrong: table-based EH makes non-throwing try blocks almost free; removing them causes leaks. Do instead: keep proper cleanup; focus on not throwing in hot paths.

3. Assuming .NET 9 made exceptions "cheap"

What happens: returning to exception-driven designs after the .NET 9 improvement. Why it is wrong: throws are still orders of magnitude slower than returns and still allocate. Do instead: treat the improvement as a bonus for genuinely exceptional cases.

4. Formatting expensive messages eagerly

What happens: exception messages built with large interpolations, serialised objects or database lookups. Why it is wrong: adds cost to every throw and may leak data. Do instead: keep messages concise; put structured data in properties or logs.

5. Reusing exception instances

What happens: static readonly NotFoundException Instance = new(); thrown repeatedly to "save allocations". Why it is wrong: stack traces are overwritten and shared across threads, corrupting diagnostics; the throw itself is still expensive. Do instead: don't throw on hot paths; if you must throw, create a new exception.

6. Logging full exception details for high-rate expected failures

What happens: every invalid request logs an error with a full stack trace. Why it is wrong: formatting stack traces and ingesting logs costs CPU and money; alerts become noisy. Do instead: handle expected failures without exceptions and log them at a low level with sampling.

7. Inline throws in tiny hot methods

What happens: small accessors containing throw new ... statements. Why it is wrong: can prevent inlining and bloat hot code. Do instead: use ThrowIf helpers or [DoesNotReturn] throw-helper methods.

8. Not monitoring exception rates

What happens: nobody watches exception-count; exceptions in normal flow go unnoticed. Why it is wrong: hidden CPU cost and masked bugs (first-chance exceptions caught and ignored). Do instead: add exception rate to dashboards and alert on anomalies.

9. Converting every error to a result type

What happens: database outages, null references and bugs are wrapped in Result objects. Why it is wrong: callers end up ignoring or mishandling infrastructure failures; propagation boilerplate explodes. Do instead: results for expected domain outcomes, exceptions for unexpected failures.

10. Catching exceptions deep in async chains just to return defaults

What happens: catch { return null; } in repositories to "avoid exceptions bubbling". Why it is wrong: hides failures and still pays the throw cost. Do instead: let unexpected exceptions propagate to the global handler; return explicit results for expected cases.

๐Ÿ› Senior insight: trade-offs

Seniors separate correctness from performance: exceptions are for exceptional or broken conditions, and the cost only matters when exceptions become frequent.

Signal Interpretation Action
Exception rate high, user errors low exceptions as control flow switch to Try or results
Exceptions spike with latency failing dependency resilience, circuit breaker
Logs dominated by one exception noisy expected case handle it explicitly
CPU in stack trace formatting heavy logging of exceptions log once, sample, lower level

They also mention that upgrading to .NET 9 or 10 makes exception-heavy code noticeably faster for free, but that is a reason to upgrade, not a reason to keep exceptions on hot paths.

๐Ÿ”— Follow-ups the interviewer may ask

  1. When: Candidate mentions measuring exception rates Why: Checks profiling with dotnet-counters and dotnet-trace
    How to answer

    First I measure: Application Insights tells me which endpoint is slow and at what percentile, and whether time goes into dependencies like SQL or HTTP. Then I reproduce it on a Release build and run the Visual Studio Performance Profiler (Alt+F2): CPU Usage for hot paths, Database for query count and duration, .NET Object Allocation Tracking for GC pressure, and .NET Async for slow awaits. In an environment where I can't use Visual Studio, dotnet-counters shows live metrics like thread pool queue length and GC, and dotnet-trace captures CPU samples. I fix the biggest cause, often an N+1 query, re-measure against the baseline, and add a load test so it doesn't regress.

  2. When: Candidate mentions parse-in-try/catch Why: Tests spotting the pattern in code review
    How to answer

    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.

๐Ÿ›  Real-world problems: how you handle them

Tens of thousands of FormatExceptions per second in a pricing import
Situation
A pricing API imported supplier price files row by row. Each field was parsed with `decimal.Parse` inside `try/catch`, and many fields were blank by design.
Signals
High CPU at import time, p99 latency spikes for other endpoints on the same pods, `dotnet-counters` showing [tens of thousands] of exceptions per second, and no corresponding errors reported to users.
Investigation
A CPU profile showed most time in exception dispatch and stack trace capture under the parser. Logs were not affected because the catch returned a default silently, which is why nobody had noticed.
Resolution
Replaced the parse-and-catch with `decimal.TryParse` using invariant culture, treated blank fields explicitly, and returned a validation result listing rows with real format errors.
Prevention
Added an exception-rate alert per service, a benchmark for the parser, and a guideline: expected failures use Try or result patterns.

How to tell it (STAR)

**Situation:** At [company], our pricing API slowed down for everyone whenever a supplier file was imported. **Task:** I was asked to find why imports affected unrelated endpoints. **Action:** `dotnet-counters` showed [tens of thousands] of exceptions per second during imports, yet no errors in our logs. A profile pointed to `decimal.Parse` inside a try/catch that returned zero for blank fields, which were common by design. I switched to `TryParse`, handled blanks explicitly, and returned a proper validation report for real format problems. I added an alert on exception rate per service. **Result:** CPU during imports dropped by [about X%], other endpoints stopped spiking, and suppliers now get a clear list of bad rows instead of silent zeros.

โ†ฉ Usually asked after

More in Exception Handling

Last updated 30 Oct 2026.

๐ŸŒ Sangam's Webring

โช Previous ๐Ÿ  Main Next โฉ
๐Ÿ’ฌ Chat with me