Smarter Error Handling in C#: Try, Result and Fewer Tears
Exceptions are powerful for diagnostics but expensive in tight loops and high traffic code. This post explains what the runtime does when you throw, why that cost adds up, and when exceptions are the right tool. You will see small, runnable C# examples that model expected failures with Try, Result and tuples, plus a minimal API that returns HTTP results without throwing. We will also cover a micro tip for library authors to keep fast paths fast.
Make exceptions exceptional in C#
I once watched a perfectly healthy API faceplant during a load test. The culprit was not bad SQL or a rogue infinite loop. It was a single throw in a hot path. Like a plot twist in The Office, it looked harmless until Michael declared bankruptcy by yelling it out loud.
In this guide we will look at what really happens when you throw, why that hurts in tight loops or high traffic, and which modern C# patterns let you keep error handling without paying a premium.
What you will learn
- What the runtime does when you throw and catch
- When exceptions are the right tool
- How to model expected failures with Try, Result, tuples, or a union style type
- How to keep APIs friendly without exceptions in the happy path
- A few micro performance tips for library code
Why throws feel slow
When you throw an exception the runtime does a lot of work so you do not have to:
- Allocate the exception object on the heap
- Capture a stack trace with method names and IL offsets
- Walk and unwind the call stack until a matching catch is found
- Consult exception handling metadata that the JIT produced for the method
- Limit inlining and other optimizations near protected regions
That is fantastic for diagnostics. It is not fantastic if it happens all the time inside a loop or a high throughput endpoint. Make exceptions rare and your CPU fans will thank you.
A tiny experiment
Let us do a toy measurement. We will simulate a failure every tenth iteration, once with exceptions and once with simple control flow. The numbers will vary per machine, but you will feel the difference.
Here is the exception version. Notice the work happens inside a try and every tenth iteration throws.
using System;using System.Diagnostics;var sw = Stopwatch.StartNew();int failures = 0;for (int i = 1; i <= 50_000; i++) { try { if (i % 10 == 0) throw new InvalidOperationException(); } catch { failures++; } }sw.Stop();Console.WriteLine($"{failures} failures in {sw.ElapsedMilliseconds} ms");Now the same logic without exceptions. We return a simple boolean instead of throwing.
using System;using System.Diagnostics;bool DoWork(int i) => i % 10 != 0;var sw = Stopwatch.StartNew();int failures = 0;for (int i = 1; i <= 50_000; i++) { if (!DoWork(i)) failures++; }sw.Stop();Console.WriteLine($"{failures} failures in {sw.ElapsedMilliseconds} ms");On most machines the no exception version finishes significantly faster with less allocation pressure. Your mileage will vary, but the direction is consistent.
When to throw on purpose
Exceptions are the fire alarm. Loud by design and exactly what you want when something impossible or external goes sideways.
Use exceptions for:
- Broken invariants or impossible states that indicate a bug
- Environmental failures like disk errors, network timeouts, permission issues
- API contracts in libraries where you must stop execution to avoid misuse
- Security violations and data corruption scenarios
When to avoid throwing
Do not throw for things you reasonably expect to happen during normal operation:
- Business rule rejections like out of stock or credit limit reached
- Validation failures and bad input
- Data not found when that is a valid outcome
- Hot paths that run very frequently
For these cases prefer explicit results.
Option 1: Try pattern
The Try pattern is built into the BCL with methods like int.TryParse. It keeps the happy path allocation free and communicates failure without raising control flow alarms.
using System;static bool TryDecodeSith(string s, out int level){ if (int.TryParse(s, out level) && level > 0) return true; level = 0; return false;}Console.WriteLine(TryDecodeSith("7", out var lvl) ? $"Sith level {lvl}" : "Training wheels");Option 2: A tiny Result type
You can model success and error with a lightweight value type. This is great for business workflows where you need an error message but not a stack trace.
public readonly record struct Result(bool Ok, string? Error);static Result AddToCart(int stock) { return stock > 0 ? new Result(true, null) : new Result(false, "Out of stock"); }var r = AddToCart(0);Console.WriteLine(r.Ok ? "Added to cart" : r.Error);Option 3: Tuples for simple checks
For quick validations a tuple keeps the code terse and clear.
static (bool ok, string? error) ValidateEmail(string email){ return email.Contains("@") ? (true, null) : (false, "Invalid email");}var check = ValidateEmail("dwight.schrute");Console.WriteLine(check.ok ? "Email looks fine" : check.error);Using results in ASP.NET Core minimal APIs
Web APIs often return NotFound or BadRequest, and that is perfectly normal. Return HTTP results directly and reserve exceptions for truly exceptional cases.
using Microsoft.AspNetCore.Builder;using Microsoft.AspNetCore.Http;var builder = WebApplication.CreateBuilder();var app = builder.Build();app.MapGet("/orders/{id:int}", (int id) => id == 42 ? Results.Ok(new { id, status = "shipped" }) : Results.NotFound());app.Run();Library tip: centralize throws to help the JIT
In library code you can move throw sites into helpers. This keeps fast paths friendly to inlining while preserving clear exceptions for invalid input.
using System.Diagnostics.CodeAnalysis;static class ThrowHelper { [DoesNotReturn] public static void ArgNull(string name) => throw new ArgumentNullException(name); }string BrewPotion(string herb) { if (herb is null) ThrowHelper.ArgNull(nameof(herb)); return "✨"; }Logging and observability
Even when you avoid throwing for expected outcomes, you still want insight. Log failures with context and count them. When you do catch an exception, capture it once with structured properties like correlation id, user id, and request path. That gives you the narrative without spamming the runtime with control flow that slows you down.
Quick checklist
- Throw for broken invariants and environmental failures
- Return results for expected failures
- Prefer Try methods in hot paths
- Keep exception usage off the steady state path
- Measure in the scenarios that matter to you
Wrap up
Exceptions are a gift for diagnosis and a tax on throughput. Make them rare in the happy path, model expected failures explicitly, and keep your API responses honest. With a few small shifts your code gets clearer, your logs get cleaner, and your servers stop sounding like Tie Fighters during a deploy.