C# Code Optimization Techniques Every .NET Developer Should Know
Performance problems in .NET applications rarely come from one dramatic mistake. They build up from small inefficiencies: unnecessary allocations, inefficient string handling, careless LINQ usage, or collections used the wrong way. Applying the right code optimization techniques won’t turn a slow app into a fast one overnight, but it will remove the friction that accumulates over time and makes the runtime work harder than it needs to.
Start with Measurement, Not Guesswork
Before changing anything, profile the application. Tools like Visual Studio’s built-in profiler, dotTrace, or BenchmarkDotNet show where time and memory are actually spent. Optimizing code that isn’t a bottleneck wastes effort and often makes the codebase harder to read for no real gain. Treat profiling as the first step in any optimization work, not an afterthought.
Nearby on the topic: Code Optimization: Tips and Tricks for Writing Efficient and Clean Code, Best Code Optimization Tools and Software to Speed Up Your Workflow and Code Refactoring Explained: Techniques and Real-World Examples.
Reduce Unnecessary Allocations
The .NET garbage collector is efficient, but every allocation still has a cost, and frequent allocations lead to more GC pauses. Some of the most common sources of avoidable allocations include:
- Concatenating strings in loops instead of using StringBuilder
- Boxing value types when passing them to methods expecting object
- Creating new collections or arrays inside frequently called methods
- Using LINQ in hot paths where it generates intermediate enumerators and closures
Switching to Span<T> and Memory<T> for working with slices of arrays or strings avoids copying data and cuts allocations significantly, especially in parsing or buffer-heavy code.
String Handling
Strings are immutable in C#, so every concatenation creates a new string object. In loops or when building large text output, StringBuilder is the standard fix. For simple formatting, string interpolation is fine, but repeated interpolation inside tight loops should be avoided in favour of a single builder instance.
Boxing and Unboxing
Boxing wraps a value type in an object on the heap, and unboxing reverses that. This happens silently when value types are passed to non-generic collections like ArrayList or to methods accepting object. Using generic collections (List<T>, Dictionary<TKey, TValue>) avoids this cost entirely, since generics work directly with the value type.
Choose the Right Data Structures
Picking an appropriate collection type is one of the simplest code optimization techniques in C# with a measurable effect. A few practical rules:
- Use List<T> when you need indexed access and don’t resize frequently from the front
- Use Dictionary<TKey, TValue> for lookups instead of scanning lists with Contains or FirstOrDefault
- Use HashSet<T> when uniqueness matters more than order
- Prefer arrays over lists when the size is fixed and known in advance
Switching a linear search over a list to a dictionary lookup can turn an O(n) operation into O(1), which matters far more as data grows than most micro-optimizations elsewhere in the code.
Struct vs Class
Structs are value types and live on the stack (in most cases), avoiding heap allocation and GC pressure. They make sense for small, short-lived data like coordinates or simple value objects. However, large structs passed by value can be slower than classes because copying them takes more work than copying a reference. As a rule of thumb, keep structs under around 16 bytes, and pass larger ones by ref or in if copying becomes a concern.
LINQ: Convenient but Not Free
LINQ makes code readable, but each query involves delegate calls, iterator state machines, and often hidden allocations. In performance-sensitive code paths — tight loops, high-frequency methods, real-time processing — replacing LINQ with a plain for loop can noticeably reduce overhead. This doesn’t mean abandoning LINQ everywhere; it means reserving it for code where readability matters more than raw speed.
Async/Await Done Right
Asynchronous code improves throughput by freeing threads during I/O-bound work, but misuse adds overhead instead of removing it. A few points worth following:
- Don’t use async for CPU-bound work without offloading it to a background thread
- Avoid async void except for event handlers
- Use ConfigureAwait(false) in library code to avoid unnecessary context switching
- Avoid blocking on async code with .Result or .Wait(), which can cause deadlocks
Caching and Memoization
If a method performs an expensive calculation or database call with the same input repeatedly, caching the result avoids redundant work. In-memory caching with MemoryCache or a simple dictionary-based cache works well for short-lived data. For distributed applications, an external cache like Redis keeps results consistent across instances while still cutting repeated computation.
Compiler and Runtime Settings
Beyond code-level changes, .NET offers project-level settings that affect performance:
- ReadyToRun compilation reduces startup time by pre-compiling IL to native code
- Server GC mode improves throughput on multi-core servers compared to Workstation GC
- Trimming and Native AOT (available since .NET 7) reduce binary size and startup latency for deployed applications
These settings don’t replace good code, but they complement it, and together they often produce more efficient code than either approach alone.
Conclusion
None of these code optimization techniques require rewriting an application from scratch. Most involve small, deliberate choices: picking the right collection, avoiding needless allocations, using async correctly, and measuring before and after each change. Applied consistently across a codebase, they add up to a noticeably faster, more predictable application without sacrificing readability.


