The real cost of exceptions in modern .NET, how to measure exception rates, and the patterns that keep them off hot paths.
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.
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.
StackTrace to text (with PDB line lookups) is even more expensive, but happens only if you read it or log it.finally blocks, restoring registers, then running the catch.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.
.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.
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:
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.
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.
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.dotnet-trace with the exceptions keyword, or PerfView, to see where first-chance exceptions are thrown โ including ones that are caught and hidden.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.
Checks understanding of runtime costs and pragmatic error design.
try/finally for performance, or replaces all exceptions with error codes.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.
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.
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.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.
Good Try methods:
bool and use out for the result, annotated with [NotNullWhen(true)] / [MaybeNullWhen(false)] for nullable analysis.Parse/TryParse) for callers who treat failure as exceptional.out isn't available; return a tuple, nullable, or result type: Task<(bool Found, Order? Order)> or Task<Order?>.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(...)).
[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.
awaits.exception-count in dotnet-counters while sending invalid input to an endpoint that uses Parse vs TryParse.DOTNET_LegacyExceptionHandling=1 on .NET 9+ to compare old and new EH performance.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.");
}
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.
try/using for performanceWhat 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:** 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.
Last updated 30 Oct 2026.