.NET performance patterns — allocation-free code, pooling, Span<T>, and benchmarking
Adam Sitnik and Ben Adams' core belief: Allocations are the enemy. Every heap allocation is future GC work. The fastest code is the code that never allocates.
"Don't measure, don't guess. Benchmark, allocate less, and let the stack do the work."
Span<T> is a stack-only view over contiguous memory. No allocations, no copies.
Not this:
string input = "2026-03-01T14:30:00";
string datePart = input.Substring(0, 10); // new string allocated
string timePart = input.Substring(11); // another allocation
This:
ReadOnlySpan<char> input = "2026-03-01T14:30:00";
ReadOnlySpan<char> datePart = input[..10]; // slice, no allocation
ReadOnlySpan<char> timePart = input[11..]; // slice, no allocation
int year = int.Parse(input[..4]); // parse directly from span
Key rules:
Span<T> is stack-only (ref struct) — cannot live on the heap, no asyncMemory<T> is the heap-safe sibling — use across async boundariesReadOnlySpan<T> for safe reads — prevents accidental mutation// Memory<T> for async scenarios
async Task ProcessAsync(Memory<byte> buffer)
{
int bytesRead = await stream.ReadAsync(buffer);
Process(buffer.Span[..bytesRead]);
}
Not this:
byte[] buffer = new byte[4096]; // GC pressure in loops
int read = stream.Read(buffer);
This:
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
int read = stream.Read(buffer.AsSpan(0, 4096));
ProcessData(buffer.AsSpan(0, read));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer); // ALWAYS return
}
Critical rules:
clearArray: true on Return if buffer held sensitive data// MemoryPool<T> for IMemoryOwner with automatic disposal
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(4096);
await stream.ReadAsync(owner.Memory[..4096]);
Not this:
string FormatReport(IEnumerable<Item> items)
{
var sb = new StringBuilder(); // new allocation every call
foreach (var item in items)
sb.AppendLine($"{item.Name}: {item.Value}");
return sb.ToString();
}
This:
private static readonly ObjectPool<StringBuilder> _sbPool =
new DefaultObjectPoolProvider().CreateStringBuilderPool();
string FormatReport(IEnumerable<Item> items)
{
var sb = _sbPool.Get();
try
{
foreach (var item in items)
sb.AppendLine($"{item.Name}: {item.Value}");
return sb.ToString();
}
finally
{
_sbPool.Return(sb); // cleared and returned to pool
}
}
ASP.NET DI: register DefaultObjectPoolProvider as singleton, then provider.Create(new StringBuilderPooledObjectPolicy()).
Not this:
byte[] temp = new byte[128]; // heap allocation for tiny buffer
Encoding.UTF8.GetBytes(input, temp);
This:
Span<byte> temp = stackalloc byte[128];
int written = Encoding.UTF8.GetBytes(input, temp);
ReadOnlySpan<byte> result = temp[..written];
Safety pattern — fall back to pool for large inputs:
int maxBytes = Encoding.UTF8.GetMaxByteCount(input.Length);
byte[]? rented = null;
Span<byte> buffer = maxBytes <= 256
? stackalloc byte[256]
: (rented = ArrayPool<byte>.Shared.Rent(maxBytes));
try
{
int written = Encoding.UTF8.GetBytes(input, buffer);
Process(buffer[..written]);
}
finally
{
if (rented is not null)
ArrayPool<byte>.Shared.Return(rented);
}
ref struct constraints: Cannot be boxed, captured in closures, used in async, or stored as fields in classes.
Not this:
string result = "";
foreach (var item in items)
result += item.Name + ", "; // O(n^2) allocations
This:
string result = string.Join(", ", items.Select(i => i.Name));
// string.Create for pre-sized zero-intermediate-allocation formatting
string header = string.Create(37, guid, (span, g) =>
{
"REQUEST-".AsSpan().CopyTo(span);
g.TryFormat(span[8..], out _, "N");
});
Comparison traps:
// NOT THIS — culture-sensitive, slow, allocates
if (input.ToLower() == "admin") { }
// THIS — ordinal is 5-10x faster
if (input.Equals("admin", StringComparison.OrdinalIgnoreCase)) { }
Frozen collections for read-only lookups (.NET 8):
private static readonly FrozenDictionary<string, Handler> Handlers =
new Dictionary<string, Handler>(StringComparer.OrdinalIgnoreCase)
{
["GET"] = handleGet, ["POST"] = handlePost, ["PUT"] = handlePut,
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
Boxing traps:
// NOT THIS — boxing int to object
void Log(object value) => Console.WriteLine(value);
Log(42); // boxes int to heap
// THIS — generic avoids boxing
void Log<T>(T value) => Console.WriteLine(value);
Log(42); // no boxing
Closure allocations: Lambdas that capture local variables allocate a closure object. In hot paths, use static lambdas with explicit state parameters to avoid the allocation.
params ReadOnlySpan<T> (C# 13):
// NOT THIS — params allocates an array every call
void Log(params string[] messages) { }
// THIS — zero heap allocation
void Log(params ReadOnlySpan<string> messages) { }
Struct vs class quick rule:
[MemoryDiagnoser] // Track allocations — the most important diagnoser
[SimpleJob(RuntimeMoniker.Net80)]
public class ParsingBenchmarks
{
private string _input = "12345";
[Benchmark(Baseline = true)]
public int IntParse() => int.Parse(_input);
[Benchmark]
public int SpanParse() => int.Parse(_input.AsSpan());
[Params(10, 100, 1000)]
public int ItemCount { get; set; }
[Benchmark]
public string Concat()
{
string r = "";
for (int i = 0; i < ItemCount; i++) r += i.ToString();
return r;
}
[Benchmark]
public string Builder()
{
var sb = new StringBuilder();
for (int i = 0; i < ItemCount; i++) sb.Append(i);
return sb.ToString();
}
}
Reading results: Mean (avg time), Allocated (bytes/op, target 0 B), Gen0/Gen1/Gen2 (GC collections, Gen2 = bad). Always Release mode. Always [MemoryDiagnoser]. Run: dotnet run -c Release -- --filter '*Benchmarks*'
Not this:
// Cache stampede — 100 concurrent misses all fetch
if (!_cache.TryGetValue(key, out Data data))
{
data = await _db.FetchAsync(key);
_cache.Set(key, data, TimeSpan.FromMinutes(5));
}
This:
private static readonly SemaphoreSlim _lock = new(1, 1);
public async Task<Data> GetDataAsync(string key)
{
if (_cache.TryGetValue(key, out Data data))
return data;
await _lock.WaitAsync();
try
{
if (_cache.TryGetValue(key, out data)) // double-check after lock
return data;
data = await _db.FetchAsync(key);
_cache.Set(key, data, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
SlidingExpiration = TimeSpan.FromMinutes(1),
Size = 1
});
return data;
}
finally { _lock.Release(); }
}
Structs help when:
public readonly record struct Color(byte R, byte G, byte B, byte A); // 4 bytes
public readonly record struct Vector2(float X, float Y); // 8 bytes
// Arrays: contiguous memory, no object headers, cache-friendly
Vector2[] positions = new Vector2[10_000]; // 80 KB contiguous
// Class equivalent: 240 KB scattered across heap
Structs hurt when:
// TOO LARGE — 40 bytes copied on every pass-by-value
public struct LargeStruct { decimal Price; decimal Quantity; DateTime Ts; }
// MUTABLE — modifies a copy, original unchanged
public struct MutablePoint { public int X, Y; }
var list = new List<MutablePoint> { new() { X = 1 } };
list[0].X = 5; // ERROR: modifies a copy
// BOXED — defeats the purpose
IComparable c = new MyStruct(); // heap allocation
The struct checklist: <= 16 bytes? Immutable? No boxing? Frequently allocated? If any "no," use a class.
| Anti-Pattern | Why It Hurts | Fix |
|---|---|---|
new byte[N] in loops | GC pressure per iteration | ArrayPool<byte>.Shared.Rent(N) |
str.ToLower() == "x" | Allocates, culture bugs | StringComparison.OrdinalIgnoreCase |
params T[] on hot paths | Array allocation per call | params ReadOnlySpan<T> (C# 13) |
| Capturing locals in lambdas | Closure object allocated | Static lambdas with state parameter |
| Large mutable structs | Expensive copies, confusing | Class or readonly record struct |
Missing [MemoryDiagnoser] | Benchmarks ignore allocations | Always add to benchmark classes |
Dictionary for static lookups | Mutable overhead | FrozenDictionary (.NET 8) |
Need temporary buffer?
├── Small + sync → stackalloc + Span<T>
├── Variable size → ArrayPool<T>.Rent/Return
└── Async boundary → MemoryPool<T> + Memory<T>
Need string work?
├── Parsing/slicing → ReadOnlySpan<char>
├── Building output → StringBuilder (pooled) or string.Create
└── Comparing → StringComparison.Ordinal[IgnoreCase]
Need object reuse?
├── Arrays → ArrayPool<T>
├── StringBuilder → ObjectPool<StringBuilder>
└── Custom expensive objects → ObjectPool<T>
Struct or class?
├── <= 16 bytes + immutable + no boxing → readonly record struct
├── Large or mutable or inherited → class
└── Unsure → class (safer default)
"If you haven't measured it, you haven't optimized it — you've just guessed." — Adam Sitnik