Quick Answer: To optimize .NET performance, measure a baseline first, profile to find the real bottleneck, then apply one change at a time and keep only the ones that produce a measurable win. Most gains come from cutting allocations, avoiding unnecessary work on hot paths, and tuning runtime settings like Dynamic PGO and garbage collector mode, always validated with a benchmark.
Most performance advice for .NET is a list of clever tricks. Unroll this loop, switch to pointers, pool everything. The trouble is that tricks applied to the wrong code make no difference, and some make the code harder to maintain for nothing. EgorBo's BlazingFastDotnet repository takes a different approach. It collects tips for tuning .NET 10 and 11 workloads, mostly for benchmarks, and it insists on a discipline that's easy to skip: measure, change one thing, measure again. Here's how to apply that discipline when you optimize .NET performance.
Start With a Baseline, Not a Guess
Before you touch a line of code, you need a number you trust. That's harder than it sounds. A single stopwatch reading tells you almost nothing, because JIT tiering, cache state, and background GC activity all move the result around.
BenchmarkDotNet handles most of this for you. It runs warm-up iterations, computes statistics across multiple runs, and reports allocations when you add [MemoryDiagnoser]. Put the code you're optimizing in a benchmark class, run it in Release mode, and save the output. That output is your baseline.
Here's where most people go wrong. They benchmark a synthetic input that doesn't resemble production data, then optimize for the wrong shape of work. Feed the benchmark the sizes, shapes, and concurrency your real app sees, or the numbers won't carry over.
Profile to Find the Actual Bottleneck
Once you have a baseline, find out where the time goes. The repo points to Linux perf combined with Brendan Gregg's FlameGraph scripts for this. A flame graph shows call stacks as stacked bars, where the width of each bar reflects how much CPU time that frame consumed. Wide bars at the top are your targets.
On Windows or cross-platform setups, the .NET team's own dotnet-trace and dotnet-counters tools do similar work. You'll see GC pause counts, allocation rates, and JIT activity alongside CPU samples.
The counter-intuitive part: the method that looks slowest in your head is often not the one that's slowest in the profile. Hand-tuning a tight loop that accounts for two percent of runtime can't move the needle much. Removing one allocation from a method that runs on every request can.
That said, a profile can mislead you too. Inlining changes the stacks you see, and a method that shows up as a bottleneck may just be a wrapper around the real cost. Check the callers before you commit to a fix.
One Change at a Time, Keep Only Measured Wins
This is the rule that separates real optimization from superstition. If you change three things and performance improves, you don't know which change helped, and one of the three may have made things worse. Apply one change, re-run the benchmark, compare against the baseline, and keep it only if the difference is larger than the run-to-run noise.
Noise is the hidden trap. Two runs of the same code can differ by several percent. If your "improvement" is within that band, it's not an improvement. Rerun the benchmark a few times, check the standard deviation BenchmarkDotNet reports, and only trust differences that clear it.
Most people stop here. Don't. Commit each accepted change separately so you can revert any of them later if a different workload regresses.
Attack Allocations First
For most managed workloads, allocation volume drives both throughput and pause time. Every allocation is cheap individually, but a hot path that allocates on every call pushes the garbage collector to run more often, and GC work shows up in latency tails.
Common culprits include:
- Boxing value types into
object, often hidden inside string formatting or non-generic collections. - Closures and lambdas that capture variables on hot paths, forcing a display class allocation.
- Temporary arrays and strings built with concatenation inside loops.
- LINQ chains in per-request code, where each operator allocates an enumerator.
The fixes are usually boring. Use generic overloads so value types aren't boxed. Hoist lambdas out of loops. Use stackalloc or Span<T> for small temporary buffers, which keeps them on the stack. Use StringBuilder or string.Create when you're building strings.
Now the part that matters for this repo. The author explicitly excludes ArrayPool<T>, saying manual rent-and-return is too error-prone and leads to use-after-return and double-return bugs. You can respect that stance and still cut allocations substantially. The trade-off is that stackalloc is limited to modest sizes, so large buffers will still hit the heap. Know where that line falls for your workload.
Also remember that the repo excludes anything requiring an unsafe context. That rules out pointer tricks and Unsafe class calls. You give up some peak speed, but you keep code that the JIT can reason about cleanly and that other engineers can read.
Runtime Settings: Useful, But Workload-Specific
Runtime configuration can produce large gains without touching your code, but it's where people most often apply someone else's settings blindly.
Dynamic PGO has been on by default since .NET 8. It lets the JIT use runtime profile data to optimize hot methods, such as guarded devirtualization and better block layout. You rarely need to tune it, but you should know it exists, because it's one reason a benchmark's first iterations can be slower than its steady state.
Tiered compilation starts methods at a quick, lightly optimized tier and promotes hot ones later. Short benchmarks that finish before promotion measure the wrong thing. Give BenchmarkDotNet enough warm-up to reach steady state, and compare against a warmed-up production-like run.
Server GC versus Workstation GC is a real trade-off. Server GC uses a heap and thread per core and tends to give higher throughput for server workloads, but it uses more memory and can behave differently under low load. Set it in the project file or with DOTNET_gcServer, then measure both. Don't copy a setting from a blog post because it helped their service.
Here's a quick comparison of the main measurement tools:
| Tool | Best for | Output | Main trade-off |
|---|---|---|---|
| BenchmarkDotNet | Micro and macro benchmark comparisons | Mean, stddev, allocations per op | Measures isolated code, not full app behavior |
| perf + FlameGraph | CPU hotspots on Linux | Interactive SVG flame graph | Linux only; needs symbol setup for JIT frames |
| dotnet-trace | Cross-platform CPU and allocation traces | .nettrace files for PerfView or Visual Studio | Adds overhead; trace files can be large |
| dotnet-counters | Live GC, JIT, and thread metrics | Real-time counter values | Shows trends, not which code caused them |
Why Your Benchmark Numbers Keep Changing
Benchmark variance is the most common frustration, and it has several causes. CPU frequency scaling, thermal throttling, background processes, and noisy neighbors on shared CI runners all shift results. Tiered JIT promotion timing can change which code version is measured. Garbage collection can land in one run and miss the next.
Mitigate what you can. Pin the machine's power profile, close heavy applications, and run benchmarks multiple times. Look at the error and standard deviation, not just the mean. If you compare results from different machines, you're comparing hardware as much as code, so keep comparisons on the same box.
Some variance is irreducible. Accept it, and set your threshold for "real improvement" above it.
Frequently Asked Questions
What is Blazing Fast .NET?
Blazing Fast .NET is a repository by EgorBo that collects tips and tricks for tuning .NET 10 and 11 workloads, mostly for benchmarks. It's packaged as a SKILL.md file meant to be consulted on demand. The author notes it's not an official Microsoft guide, so measure every change yourself.
How do I optimize .NET performance without using unsafe code?
Start by measuring with BenchmarkDotNet, then profile to find hot paths. Focus on reducing allocations through generic overloads, Span<T>, stackalloc for small buffers, and avoiding closures in loops. Keep only changes that produce a measurable improvement over the baseline.
Why do my .NET benchmark results change between runs?
Benchmark variance comes from CPU frequency scaling, background processes, tiered JIT promotion timing, and GC activity. Use BenchmarkDotNet's warm-up and multiple iterations, and compare results only when the difference exceeds the reported standard deviation.
Should I use Server GC or Workstation GC for performance?
It depends on the workload. Server GC usually gives higher throughput for server applications but uses more memory. Workstation GC suits client apps and low-load scenarios. Test both with your real workload before committing to either.
Closing Thoughts
The single most useful habit for any .NET optimization is simple: measure a baseline, change one thing, and keep it only if the win is larger than the noise. Reduce allocations on hot paths first, then tune runtime settings with evidence. Try running BenchmarkDotNet on your slowest method this week, record the allocations column, and note the result before you change anything. Pass this to a teammate who's stuck guessing at why the service got slower.