High-performance .NET code rarely comes from clever tricks. It comes from knowing where time and memory actually go and removing the waste that matters. This guide is for experienced C# developers who own latency-sensitive services, libraries or data pipelines and want techniques that hold up under a profiler rather than folklore. You will learn a measure-first workflow, allocation reduction with pooling and spans, struct and collection choices, when LINQ and async cost too much, vectorization with SearchValues and Vector256, caching, and garbage collector settings, with notes on what changed in .NET 8, 9, 10 and 11.
What Makes .NET Code Slow?#
When a .NET service misses its latency or cost target, the cause almost always falls into one of four buckets. The order matters, because it is roughly the order of payoff:
- Unnecessary work. Recomputing values that could be cached, calling a database once per item instead of once per batch, or serializing data nobody reads. No micro-optimization beats not doing the work at all.
- Waiting. Network and disk latency, lock contention, and thread-pool starvation caused by blocking calls. The CPU looks idle while requests queue up.
- Allocation pressure. Allocating on the managed heap is cheap, usually a pointer bump, but every allocated byte brings the next garbage collection closer. Short-lived gen0 garbage is inexpensive. Objects that survive into gen2, and temporary arrays of 85,000 bytes or more that land on the large object heap (LOH), are not.
- Inefficient CPU work in hot loops. Interface calls that block inlining, redundant bounds checks, poor cache locality, and scalar code where SIMD could process 8 or 16 elements per instruction.
The runtime already removes much of the fourth bucket for you. Tiered compilation with Dynamic PGO (profile-guided optimization) has been on by default since .NET 8: hot methods are recompiled using profile data gathered while the app runs, and Microsoft measured an average gain of about 15% across its benchmark suite when the feature was switched on. .NET 10 continued this "de-abstraction" work. Its JIT devirtualizes array interface methods, stack-allocates small fixed-size arrays and some non-escaping delegates, and inlines methods that contain try/finally. Two practical consequences follow: upgrading the runtime is often the cheapest optimization available, and tricks learned on .NET Framework may no longer pay for their complexity.
How to Approach .NET Performance: Measure First#
Performance intuition is unreliable, even for experts, so work in a loop and let data choose the target:
- Set a numeric goal, such as a p99 latency under 50 ms at 2,000 requests per second, or a pod that stays under 512 MB.
- Observe the running system with low-overhead metrics: CPU, allocation rate, GC count and pause time, thread-pool queue length and lock contention.
- Profile to find the hot path. A sampled CPU trace or an allocation trace shows which call stacks dominate.
- Reproduce the hot path in a microbenchmark and compare alternatives with statistics rather than stopwatch timings.
- Change one thing, then re-measure end to end. A method that becomes three times faster but accounts for 2% of request time is a rounding error.
The .NET CLI diagnostics tools cover steps 2 and 3 without installing a profiler on the server:
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-trace
# Live runtime metrics: GC, allocation rate, thread pool, lock contention, exceptions
dotnet-counters monitor --name OrderApi --counters System.Runtime
# 30 seconds of sampled managed stacks plus GC, JIT and exception events
dotnet-trace collect --name OrderApi --duration 00:00:00:30Open the resulting .nettrace file in Visual Studio or PerfView, or ask dotnet-trace to write Speedscope format instead. The .NET Diagnostics Toolkit guide covers these tools in depth, including how to use them safely inside containers.
Getting Started: Benchmark Before You Optimize#
Once profiling points at a method, isolate it in a BenchmarkDotNet console project. [MemoryDiagnoser] adds the bytes allocated per operation, which often predicts production behavior better than raw nanoseconds:
using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<CsvLineBenchmarks>();
[MemoryDiagnoser]
public class CsvLineBenchmarks
{
private readonly int[] _values = Enumerable.Range(1, 64).ToArray();
[Benchmark(Baseline = true)]
public string Concatenation()
{
var line = string.Empty;
foreach (var value in _values)
{
line += value + ","; // allocates a new, longer string on every iteration
}
return line;
}
[Benchmark]
public string PresizedBuilder()
{
var sb = new StringBuilder(_values.Length * 4);
foreach (var value in _values)
{
sb.Append(value).Append(',');
}
return sb.ToString();
}
}Run it with dotnet run -c Release. BenchmarkDotNet handles warmup, iteration counts, outliers and dead-code elimination, all of which hand-rolled Stopwatch loops tend to get wrong. The BenchmarkDotNet guide explains how to read the results and compare runtimes.
Reduce Allocations in Hot Paths#
Allocation reduction pays off twice: less time spent allocating and zeroing memory, and fewer garbage collections for the whole process, including threads that did not allocate. Focus on code that runs per request, per message or per item in a large loop. Allocations during startup do not matter.
Rent Buffers from ArrayPool Instead of Allocating Them#
Temporary buffers are the classic waste. ArrayPool<T>.Shared hands out arrays from per-core caches and takes them back when you are done:
using System.Buffers;
public static async Task<long> CountLinesAsync(Stream stream, CancellationToken ct)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
long lines = 0;
int read;
while ((read = await stream.ReadAsync(buffer.AsMemory(), ct)) > 0)
{
lines += buffer.AsSpan(0, read).Count((byte)'\n'); // vectorized in .NET 8+
}
return lines;
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}Three rules keep pooling safe. Rent may return a larger array than you asked for, so always track the valid length yourself. Never touch an array after returning it, because another thread may already own it. Pass clearArray: true to Return when the buffer held secrets or personal data. For small buffers of known size, stackalloc into a Span<T> avoids even the pool; the Span and Memory guide explains the safety rules.
Build Strings Without Intermediate Copies#
Strings are immutable, so every Substring, concatenation and ToUpper call allocates. Three tools remove most of that churn:
- Spans slice without copying.
text.AsSpan(start, length)gives a view, and span-based parsing APIs such asint.Parse(ReadOnlySpan<char>)and the spanSplitmethods added in .NET 8 and 9 keep it that way. - Interpolated strings compile to
DefaultInterpolatedStringHandlersince C# 10, which formats value types without boxing them into a rented buffer. string.Createallocates the final string once and lets you write its characters in place.
public static string MaskCardNumber(string cardNumber)
{
ArgumentOutOfRangeException.ThrowIfLessThan(cardNumber.Length, 4);
// One allocation: the result string. The static lambda cannot capture state by accident.
return string.Create(cardNumber.Length, cardNumber, static (span, source) =>
{
span.Fill('*');
source.AsSpan(source.Length - 4).CopyTo(span[^4..]);
});
}Pool Expensive Objects with ObjectPool#
For objects that are costly to construct, Microsoft.Extensions.ObjectPool provides a thread-safe pool; ASP.NET Core uses it internally to reuse StringBuilder instances. Types that implement IResettable (.NET 8 and later) clean themselves up when they return to the pool:
using System.Text;
using Microsoft.Extensions.ObjectPool;
// Program.cs registrations
builder.Services.AddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>();
builder.Services.AddSingleton(sp =>
sp.GetRequiredService<ObjectPoolProvider>().CreateStringBuilderPool());
builder.Services.AddSingleton(sp =>
sp.GetRequiredService<ObjectPoolProvider>().Create<ReportBuffer>());
public sealed class ReportBuffer : IResettable
{
public List<decimal> Totals { get; } = new(capacity: 1_024);
public bool TryReset()
{
Totals.Clear(); // keep the capacity, drop the contents
return true; // returning false tells the pool to discard this instance
}
}
public sealed class ReportRenderer(ObjectPool<StringBuilder> builders)
{
public string Render(IEnumerable<(string Sku, int Quantity)> lines)
{
StringBuilder sb = builders.Get();
try
{
foreach (var (sku, quantity) in lines)
{
sb.Append(sku).Append(';').Append(quantity).AppendLine();
}
return sb.ToString();
}
finally
{
builders.Return(sb);
}
}
}Pooling is not automatically a win. Getting an object from a pool costs more than allocating a small, cheap object, and pooled objects live until the pool dies, which pushes them into gen2. Note also that the default pool limits how many objects it retains, not how many it creates. Pool when construction is expensive or the object holds a large buffer, and prove the benefit with a benchmark.
Avoid Boxing and Hidden Closures#
Two allocation sources hide in innocent-looking code. Boxing happens when a value type is converted to object or to an interface: passing a struct through an IComparable parameter, calling a params object[] API, or storing value types in non-generic collections. Closures happen when a lambda captures a local variable, a parameter or this: the compiler allocates an object to hold the captured state, plus a new delegate each time the lambda expression is evaluated.
// Captures tenantId: allocates a closure and a delegate on every call, even on cache hits
decimal price = _prices.GetOrAdd(sku, key => LoadPrice(key, tenantId));
// Passes state explicitly; 'static' turns an accidental capture into a compile error
decimal cached = _prices.GetOrAdd(
sku, static (key, tenant) => LoadPrice(key, tenant), tenantId);
// Struct keys: the default ValueType.Equals can box and fall back to reflection.
// A record struct generates IEquatable<T>, so dictionary lookups stay allocation-free.
public readonly record struct PriceKey(int TenantId, string Sku);Other common traps are logging calls that box their arguments (the [LoggerMessage] source generator avoids this), string.Format with value types, and interface-typed parameters where a generic constraint such as where T : IComparable<T> would let the JIT generate specialized, non-boxing code for each value type. .NET 10's escape analysis can stack-allocate some delegates and closures that never leave their method, but treat that as a bonus rather than a design strategy: it only applies when the JIT can prove the delegate does not escape, which excludes anything stored in a field or passed to a method that is not inlined.
Structs vs Classes: Choosing the Right Trade-Off#
The struct-versus-class decision is about copying cost, allocation count and identity. Structs are stored inline, on the stack or inside their containing object or array, so an array of one million structs is a single allocation with excellent cache locality, while an array of one million class instances is a million and one allocations scattered across the heap. The price is that structs are copied on every assignment, argument pass and return, unless you pass them with in, ref or ref readonly.
The Framework Design Guidelines rule of thumb still holds: consider a struct when instances are small (under about 16 bytes), short-lived or embedded in other objects, logically represent a single value, are immutable, and are rarely boxed. Modern C# adds features that make well-designed structs cheaper still:
readonly structguarantees immutability and stops the compiler from making defensive copies when you call members throughinparameters orreadonlyfields.record structgenerates value equality andIEquatable<T>, so the type works as a fast dictionary key.inandref readonlyparameters pass large structs by reference without allowing mutation.ref structtypes such asSpan<T>can never be boxed or escape to the heap, which is exactly what makes them safe views over stack memory.
| Consideration | Class | Struct |
|---|---|---|
| Allocation | One heap object per instance | Inline, no separate heap object |
| Copy cost | Copies an 8-byte reference | Copies every field unless passed by in or ref |
| Identity and null | Reference identity, can be null | Value semantics, Nullable<T> for null |
| Polymorphism | Inheritance and virtual dispatch | Interfaces only, boxing when used through one |
| Best for | Entities, services, large or mutable state | Small immutable values, keys, hot arrays of data |
A common mistake is converting large DTOs to structs "for performance". A 64-byte struct copied through five layers of method calls can easily be slower than a single heap allocation.
Choosing Collections for Speed#
The right collection is usually a bigger win than any micro-optimization inside a loop. Three habits cover most cases.
Presize when you know the count. List<T> and Dictionary<TKey, TValue> grow by allocating a larger backing store and copying, or rehashing, everything into it, and the abandoned arrays become garbage. Passing a capacity to the constructor, or calling EnsureCapacity, removes that churn.
Freeze read-mostly lookups. FrozenDictionary<TKey, TValue> and FrozenSet<T> in System.Collections.Frozen (.NET 8 and later) spend extra time at creation analyzing the keys to build the fastest lookup structure they can, and then never change. They suit configuration, routing tables and reference data that is built once and read millions of times.
Avoid double lookups and throwaway strings. CollectionsMarshal.GetValueRefOrAddDefault updates a dictionary entry with a single hash lookup, and .NET 9's alternate lookups let a Dictionary<string, TValue> be queried with a ReadOnlySpan<char>, so you never allocate a string just to look something up.
using System.Collections.Frozen;
using System.Runtime.InteropServices;
// Built once at startup, read on every request
private static readonly FrozenDictionary<string, decimal> s_vatRates =
LoadVatRates().ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
static Dictionary<string, int> CountTokens(ReadOnlySpan<char> text)
{
var counts = new Dictionary<string, int>(capacity: 256, StringComparer.Ordinal);
var lookup = counts.GetAlternateLookup<ReadOnlySpan<char>>(); // .NET 9+
foreach (Range range in text.Split(' ')) // allocation-free span splitting, .NET 9+
{
ReadOnlySpan<char> token = text[range];
if (token.IsEmpty)
{
continue;
}
// A string is allocated only the first time a token is seen
lookup[token] = lookup.TryGetValue(token, out int n) ? n + 1 : 1;
}
return counts;
}
static void Increment(Dictionary<string, int> counts, string key)
{
// One hash lookup instead of TryGetValue followed by the indexer setter
ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(counts, key, out _);
count++;
}| Collection | Reads | Writes | Use it for |
|---|---|---|---|
Dictionary<TKey, TValue> | Fast hash lookup | Fast, not thread-safe | Single-owner or per-request state |
FrozenDictionary<TKey, TValue> | Fastest | None after creation, slow to build | Read-mostly data built once |
ConcurrentDictionary<TKey, TValue> | Lock-free reads | Fine-grained locks | Shared caches and counters |
ImmutableDictionary<TKey, TValue> | Slower, tree-based | Allocates on every change | Snapshots shared across threads, not hot paths |
Arrays and List<T> | Fast by index, linear search | Amortized append | Sequential processing and small sets |
LINQ in Hot Paths#
LINQ is expressive and, for most code, fast enough; the runtime team optimizes it in every release. In a hot loop, though, each query costs an iterator object per operator, an enumerator, a delegate invocation per element that the JIT usually cannot inline, and a closure allocation whenever the lambda captures something. For a query executed a few times per request that is noise. For one executed per element of a million-row batch, it is not.
// Readable and fine for cold paths: iterator allocations plus a delegate call per element
decimal paid = orders.Where(o => o.Status == OrderStatus.Paid).Sum(o => o.Total);
// Hot path over a List<T>: no iterators or delegates, and span loops skip bounds checks
decimal paidFast = 0;
foreach (Order o in CollectionsMarshal.AsSpan(orders))
{
if (o.Status == OrderStatus.Paid)
{
paidFast += o.Total;
}
}
// .NET 9+: count per key without allocating an IGrouping list for every key
foreach (var (status, count) in orders.CountBy(o => o.Status))
{
Console.WriteLine($"{status}: {count}");
}CollectionsMarshal.AsSpan exposes the list's backing array, so never add or remove items while you iterate over it. A few guidelines hold up in real profiles:
- Materialize once. Enumerating a deferred query twice, or calling
Count()and then iterating, repeats all the work. - Ask cheap questions. Prefer
Any()overCount() > 0, and useTryGetNonEnumeratedCountwhen a count is only useful if it is free. - Keep LINQ out of per-element inner loops, and use it freely at the orchestration level where it improves clarity.
- Aggregate without grouping. The .NET 9
CountByandAggregateByoperators replaceGroupBywhen you only need one value per key.
Async Overhead and How to Keep It Low#
async and await are essential for scalability, but they are not free. When an async method completes synchronously, the state machine stays on the stack, although an async Task<T> still allocates its result task unless the value is one of a few cached results. When the method actually suspends, the state machine moves to the heap, the ExecutionContext is captured, and a continuation is scheduled. That overhead is tiny next to a network call, but it adds up in methods that run millions of times and usually finish synchronously, such as cache lookups and buffered reads.
public sealed class PriceService(IMemoryCache cache, IPriceRepository repository)
{
// Most calls are cache hits: ValueTask<T> returns them without allocating a Task
public ValueTask<decimal> GetPriceAsync(string sku, CancellationToken ct)
{
if (cache.TryGetValue(sku, out decimal price))
{
return ValueTask.FromResult(price);
}
return new ValueTask<decimal>(LoadAndCacheAsync(sku, ct));
}
private async Task<decimal> LoadAndCacheAsync(string sku, CancellationToken ct)
{
decimal price = await repository.GetPriceAsync(sku, ct);
cache.Set(sku, price, TimeSpan.FromMinutes(5));
return price;
}
}The rules that matter most in server code:
- Use
ValueTask<T>for hot methods that usually complete synchronously, and consume each instance exactly once: never await it twice or read.Resultbefore it completes. - Never block on async code with
.Result,.Wait()orGetAwaiter().GetResult(). Blocking holds thread-pool threads hostage and leads to thread-pool starvation, which shows up as a growing thread-pool queue and rising latency while the CPU sits idle. - Do not wrap request work in
Task.Runin ASP.NET Core. It moves work from one thread-pool thread to another and adds scheduling overhead. - Run independent I/O concurrently with
Task.WhenAll, and useTask.WhenEach(.NET 9) to process results as they complete. - Use
ConfigureAwait(false)in general-purpose libraries. ASP.NET Core has no synchronization context, so application code there gains little from it.
.NET 11 introduces Runtime Async as a preview feature. Instead of the C# compiler emitting a state machine class for every async method, the runtime itself manages suspension and resumption, which lowers overhead and produces readable live stack traces. You opt in with <Features>runtime-async=on</Features> in a net11.0 project, and the .NET 11 libraries themselves are already compiled that way. .NET 11 also skips capturing and restoring the ExecutionContext when a continuation has no ambient state to flow. The underlying model is explained in Async/Await in C#: A Deep Dive.
Vectorization with SearchValues, Vector128 and Vector256#
SIMD instructions process several elements at once: a 256-bit register holds eight int values or 32 bytes. Much of the base class library is already vectorized, including span search and comparison methods such as IndexOf, IndexOfAny, SequenceEqual and Count, UTF-8 transcoding and Base64. The first step is therefore to call these span APIs instead of writing character-by-character loops.
SearchValues<T> (.NET 8) is the tool for "find any of these values" problems. Creating an instance analyzes the set once and selects the fastest strategy for it, and every search reuses that analysis. .NET 9 extended it to search for several substrings at once, the same machinery Regex now uses internally:
using System.Buffers;
public static class InputGuards
{
// Analyzed once; the best vectorized strategy is chosen for this character set
private static readonly SearchValues<char> s_headerUnsafe = SearchValues.Create("\r\n\0");
// .NET 9+: multi-substring search, case-insensitive
private static readonly SearchValues<string> s_secretMarkers = SearchValues.Create(
["password=", "apikey=", "client_secret="], StringComparison.OrdinalIgnoreCase);
public static bool IsSafeHeaderValue(ReadOnlySpan<char> value) =>
!value.ContainsAny(s_headerUnsafe);
public static bool MayContainSecret(ReadOnlySpan<char> logLine) =>
logLine.ContainsAny(s_secretMarkers);
}When you need a custom numeric kernel, the cross-platform Vector128<T> and Vector256<T> APIs, plus Vector512<T> since .NET 8, compile to SSE and AVX instructions on x64 and to AdvSimd on Arm64 without platform-specific code. The pattern is always the same: check for hardware acceleration, process full vectors, then handle the leftover tail with scalar code.
using System.Runtime.Intrinsics;
public static int Sum(ReadOnlySpan<int> values)
{
int i = 0;
int total = 0;
if (Vector256.IsHardwareAccelerated && values.Length >= Vector256<int>.Count)
{
Vector256<int> acc = Vector256<int>.Zero;
int lastBlock = values.Length - Vector256<int>.Count;
for (; i <= lastBlock; i += Vector256<int>.Count)
{
acc += Vector256.Create(values.Slice(i, Vector256<int>.Count));
}
total = Vector256.Sum(acc); // horizontal add of the eight lanes
}
for (; i < values.Length; i++) // scalar tail
{
total += values[i];
}
return total;
}Before writing kernels by hand, check TensorPrimitives in the System.Numerics.Tensors package: as of .NET 9 it offers close to 200 vectorized span operations, including sums, dot products and cosine similarity. .NET 10 added AVX10.2 support, disabled by default because capable hardware was not yet available when it shipped, and .NET 11 adds cross-lane helpers such as Zip, Unzip and CreateGeometricSequence on every vector type. Always benchmark vectorized code against the scalar version on your production CPU, because for short inputs the setup cost can outweigh the gain.
Caching: The Fastest Code Is Code That Never Runs#
Caching removes whole categories of work, which is why "cache aggressively" opens Microsoft's ASP.NET Core performance guidance. The layers, from nearest to farthest:
- Memoize inside the process with
IMemoryCachefor data that is expensive to produce and tolerant of brief staleness. Set aSizeLimitand give every entry aSize, or the cache can grow until the process runs out of memory. - Use HybridCache (
Microsoft.Extensions.Caching.Hybrid) for an in-memory L1 plus an optional distributed L2 such as Redis. It adds stampede protection, so only one caller per key runs the factory while concurrent callers wait for its result. - Cache whole responses with ASP.NET Core output caching when the same response serves many users.
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10), // overall lifetime, also used for L2
LocalCacheExpiration = TimeSpan.FromMinutes(2) // in-process L1 lifetime
};
});
app.MapGet("/products/{id:int}", async (int id, HybridCache cache,
ProductRepository repository, CancellationToken ct) =>
await cache.GetOrCreateAsync(
$"product:{id}",
async token => await repository.GetAsync(id, token),
cancellationToken: ct));Key design, invalidation and Redis are covered in Caching in .NET. In .NET 11, MemoryCache can publish hit, miss and eviction metrics when you set TrackStatistics = true, so cache effectiveness becomes observable without custom instrumentation.
Tuning the Garbage Collector: Server GC, DATAS and Containers#
The GC defaults are good, and most "GC problems" are really allocation problems, so fix allocation hot spots before touching configuration. When settings do matter, these are the ones worth knowing:
| Setting (MSBuild or runtimeconfig.json) | Default | When to change it |
|---|---|---|
ServerGarbageCollection or System.GC.Server | Workstation for console apps, Server for ASP.NET Core | Server GC for throughput-oriented services with several cores |
ConcurrentGarbageCollection or System.GC.Concurrent | true (background GC) | Disable only in very dense deployments of many small instances |
GarbageCollectionAdaptationMode or System.GC.DynamicAdaptationMode | 1 (DATAS on) with Server GC in .NET 9+ | Set 0 if you measure a throughput loss and memory is plentiful |
System.GC.HeapHardLimitPercent | 75% of the container memory limit | Lower it when sidecars or native memory share the container |
System.GC.ConserveMemory | 0 | Values 1 to 9 trade CPU time for a smaller, more compacted heap |
System.GC.HeapCount | One heap per available core (Server GC) | Cap it when several Server GC processes share a machine |
DATAS (Dynamic Adaptation To Application Sizes) deserves special attention because it changed how Server GC behaves by default in .NET 9. Classic Server GC creates one heap per core and sizes its budgets for throughput, so the same app could use far more memory on a 48-core machine than on a 4-core one. DATAS starts with a single heap, adds heaps only when allocation demand requires them, and sizes the heap relative to the long-lived data. Microsoft's TechEmpower runs on a 48-core Linux machine showed working-set reductions of more than 80% for a 2 to 3% drop in peak requests per second. For most containerized services that is an excellent trade. For a latency-critical service on dedicated hardware, you may prefer to switch it off and measure.
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
<!-- DATAS: on by default with Server GC since .NET 9; 0 trades memory for peak throughput -->
<GarbageCollectionAdaptationMode>1</GarbageCollectionAdaptationMode>
</PropertyGroup>Environment variables are handy for per-deployment tuning, but numeric DOTNET_GC* values are parsed as hexadecimal, with or without a 0x prefix. DOTNET_GCHeapHardLimitPercent=0x46 means 70%, while a value of 70 would be read as 0x70, which is 112. Generations, modes and pause behavior are explained in Garbage Collection in .NET.
Best Practices: A Practical Performance Checklist#
- Start from a numeric goal and a profile of production-like load; never optimize code you have not measured.
- Test in Release builds on realistic hardware, with the same CPU architecture, container limits and data volumes as production.
- Stay on a current runtime. .NET 10 LTS brings stronger devirtualization and stack allocation, and recompiling on .NET 9 or later lets calls bind to the new
params ReadOnlySpan<T>overloads that avoid array allocations. - Treat allocations per request as a budget. Watch the allocation rate and gen0 collection count, and fail code reviews that add allocations to hot paths without a reason.
- Pool buffers, not everything.
ArrayPool<T>for large temporary arrays,ObjectPool<T>for expensive objects, and plain allocation for small, cheap ones. - Presize collections and freeze lookup tables that are built once and read often.
- Keep hot loops free of LINQ, closures and boxing, and keep LINQ where it makes orchestration code clearer.
- Keep async all the way down, use
ValueTask<T>only where profiling shows it helps, and never block on tasks. - Prefer built-in vectorized APIs such as span methods,
SearchValuesandTensorPrimitivesbefore writing SIMD code. - Cache with limits and metrics, so a cache never becomes a memory leak.
- Revisit GC settings last, after allocations are under control, and verify every change under load.
- Protect wins with benchmarks that run on every pull request or nightly build.
Common Pitfalls#
- Benchmarking Debug builds or running with a debugger attached, which disables most JIT optimizations.
- Micro-optimizing cold code, which costs readability and buys nothing measurable.
- Returning a pooled array twice or using it after
Return, which corrupts another caller's data in ways that are very hard to reproduce. - Converting large types to structs, turning cheap reference copies into expensive value copies.
- Awaiting a
ValueTasktwice or storing it for later, which is undefined behavior for pooled implementations. - Blocking on async code in request handlers, the most common cause of thread-pool starvation.
- Unbounded caches and static collections, which turn a performance fix into a memory leak.
- Calling
GC.Collect()in production to "free memory". It forces expensive gen2 collections and usually hides the real problem.
Which Performance Technique When?#
Use the symptom you observe in metrics or a profile to pick the technique to try first:
| Symptom | Likely cause | Try first |
|---|---|---|
| High CPU concentrated in a few methods | Algorithmic or hot-loop inefficiency | Better algorithm or collection, loops instead of LINQ, then SIMD |
| High allocation rate, frequent gen0 and gen1 GCs | Temporary objects per request | Spans, string.Create, ArrayPool<T>, no closures or boxing |
| Growing gen2 or LOH | Long-lived caches or large temporary arrays | Cache size limits, pooled large buffers, smaller chunks |
| Latency spikes while CPU is idle | Blocking calls, starvation or lock contention | Async all the way, shorter lock scopes |
| High memory per pod at low load | Server GC sized for many cores | DATAS, a heap hard limit, fewer heaps |
| Repeated expensive calls for the same data | Missing cache | IMemoryCache with limits, HybridCache, output caching |
Frequently Asked Questions#
Is .NET fast enough for high-performance workloads?#
Yes. The JIT, the garbage collector and the base libraries are engineered for throughput, and the techniques in this guide are the same ones used inside ASP.NET Core, System.Text.Json and the runtime itself. Most slow .NET services are slow because of I/O patterns, allocation churn or missing caches rather than the platform.
Should I use structs everywhere to avoid heap allocations?#
No. Structs help when they are small, immutable and used in bulk, such as keys, coordinates or elements of large arrays. Large or mutable structs are copied on every assignment and argument pass, and boxing them through interfaces allocates anyway. Measure before converting existing classes.
When is pooling slower than allocating?#
For small objects with cheap constructors, renting and returning costs more than a gen0 allocation that dies young. Pooled objects also stay alive for the life of the pool and occupy gen2. Pool large buffers and genuinely expensive objects, and confirm the gain with a benchmark.
Does upgrading to a newer .NET version improve performance automatically?#
Usually, yes. Dynamic PGO is on by default since .NET 8, recompiling on .NET 9 binds calls to allocation-free params ReadOnlySpan<T> overloads, and .NET 10 adds stack allocation and devirtualization improvements. Still run your benchmarks and load tests after upgrading, because default changes such as DATAS shift the balance between memory and throughput.
Should I use Workstation GC or disable DATAS in containers?#
It depends on density and load. DATAS already keeps Server GC memory proportional to the live data, so it suits most containerized services. For many small instances on one node, Workstation GC can reduce context switching, while a service on dedicated hardware chasing peak throughput may do better with DATAS disabled. Decide from load-test measurements, not from defaults in blog posts.
Summary#
- Measure first: set a numeric goal, observe with
dotnet-counters, profile withdotnet-trace, and verify alternatives with BenchmarkDotNet. - The biggest wins come from avoiding work: caching, batching I/O and better algorithms and collections.
- Reduce allocations in hot paths with spans,
string.Create,ArrayPool<T>and closure-free code; pool only what is expensive. - Choose structs deliberately, presize collections and freeze read-mostly lookups.
- Keep LINQ and heavy async machinery out of per-element loops, and never block on tasks.
- Use
SearchValues, span APIs andTensorPrimitivesbefore writingVector256code by hand. - Tune the GC last: Server GC with DATAS is the .NET 9+ default for ASP.NET Core, and container limits shape heap sizing.
Further Reading#
- ASP.NET Core Best Practices (Microsoft Learn)
- Runtime configuration options for garbage collection (Microsoft Learn)
- What's new in the .NET 10 runtime (Microsoft Learn)
- Object reuse with ObjectPool in ASP.NET Core (Microsoft Learn)
- Benchmarking .NET Code with BenchmarkDotNet
- .NET Diagnostics Toolkit: Counters, Traces and Dumps