Memory Management in .NET — Avoid Leaks and GC Pressure
Understand how the .NET GC works, where allocations come from, and how to use Span<T>, ArrayPool<T>, and IDisposable correctly to keep your app fast and leak-free.
How the .NET GC Works
The GC divides the managed heap into generations:
- Gen 0 — short-lived objects (most allocations). Collected frequently, very fast.
- Gen 1 — objects that survived Gen 0. Buffer between Gen 0 and Gen 2.
- Gen 2 — long-lived objects (statics, caches). Collected infrequently, expensive.
- LOH (Large Object Heap) — objects >= 85,000 bytes. Collected only with Gen 2. Never compacted by default.
The GC is generational because most objects die young. If you create lots of large, short-lived objects they land on the LOH, cause Gen 2 collections, and fragment the heap. That's expensive.
What Causes Allocations
String concatenation in loops:
// Bad — creates a new string on every iteration
string result = "";
foreach (var item in items)
result += item.Name + ", ";LINQ chains: every Select, Where, and ToList allocates an enumerator and potentially a new collection.
Boxing: converting value types to object or interface types allocates on the heap.
int x = 42;
object boxed = x; // heap allocation
IComparable c = x; // also boxing
var list = new List<object> { x }; // boxing on each addParams arrays:
void Log(string format, params object[] args) { ... }
Log("Value: {0}", 42); // allocates object[] and boxes 42Span<T> and Memory<T>
Span<T> is a stack-allocated view over a contiguous region of memory — an array, a string, stack memory, or native memory. No heap allocation.
// Slicing without allocating a new string
ReadOnlySpan<char> input = "Hello, World!".AsSpan();
ReadOnlySpan<char> hello = input.Slice(0, 5); // no allocation
// Parsing without substring
bool TryParseIntFast(ReadOnlySpan<char> input, out int result) =>
int.TryParse(input, out result);
// Zero-copy CSV column read
void ProcessCsvLine(ReadOnlySpan<char> line)
{
int comma = line.IndexOf(',');
var name = line.Slice(0, comma);
var value = line.Slice(comma + 1);
// work with name and value without allocating
}Memory<T> is the async-compatible version of Span<T> — use it when you need to pass a slice across await boundaries.
public async Task WriteChunkAsync(Memory<byte> buffer, CancellationToken ct)
{
await stream.WriteAsync(buffer, ct); // no copy, no allocation
}ArrayPool<T> to Avoid LOH Allocations
Don't allocate large temporary byte arrays — rent and return them.
// Bad — 1MB array on the LOH, never compacted
byte[] buffer = new byte[1024 * 1024];
// Good — rent from the pool
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024 * 1024);
try
{
int read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct);
Process(buffer.AsSpan(0, read));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: false);
}The pool reuses arrays, eliminating Gen 2 collections from large temporary allocations. clearArray: false skips zeroing for performance — only use true if the buffer held sensitive data.
string.Create for Hot-Path String Building
String interpolation allocates an intermediate DefaultInterpolatedStringHandler. For one-off strings it's fine. For hot paths:
// Interpolation — readable but allocates
string id = $"{prefix}-{sequence:D6}";
// string.Create — zero-intermediate-allocation
string id = string.Create(prefix.Length + 7, (prefix, sequence), static (span, state) =>
{
state.prefix.AsSpan().CopyTo(span);
span[state.prefix.Length] = '-';
state.sequence.TryFormat(span.Slice(state.prefix.Length + 1), out _, "D6");
});For general-purpose string building in a loop, StringBuilder is still the right tool — it batches allocations internally.
IDisposable and Using Patterns
Objects wrapping unmanaged resources (file handles, DB connections, HTTP connections, streams) implement IDisposable. Not disposing them leaks the resource.
// Always use using — compiler calls Dispose() even on exception
using var stream = new FileStream(path, FileMode.Open);
using var reader = new StreamReader(stream);
var content = await reader.ReadToEndAsync();
// Explicit for scoped lifetimes
using (var scope = serviceScopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.SaveChangesAsync();
} // scope.Dispose() called here, releasing db connectionImplement IDisposable yourself when your class holds disposable fields:
public class ReportGenerator : IDisposable
{
private readonly HttpClient _client;
private bool _disposed;
public ReportGenerator(HttpClient client) => _client = client;
public void Dispose()
{
if (_disposed) return;
_client.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}Detecting Memory Leaks
dotnet-counters — live process metrics:
dotnet-counters monitor --process-id <PID> System.RuntimeWatch gen-2-gc-count, loh-size, and working-set. Steady growth in gen-2-gc-count with rising working-set is the leak signature.
dotnet-dump — take a heap snapshot and inspect retained objects:
dotnet-dump collect --process-id <PID>
dotnet-dump analyze <dump-file>
# inside the REPL:
> dumpheap -stat # top types by instance count
> dumpheap -type EventHandler # look for event handler leaks
> gcroot <address> # who's holding a reference?Common Leak Patterns
Static event subscriptions:
// AppDomain.CurrentDomain.UnhandledException never goes out of scope
// Every closure captured here lives forever
AppDomain.CurrentDomain.UnhandledException += (_, e) => Log(e);
// Fix: unsubscribe in Dispose
public void Dispose() => AppDomain.CurrentDomain.UnhandledException -= _handler;Forgotten IDisposable from DI:
// This creates a DbContext that never gets disposed
var db = serviceProvider.GetService<AppDbContext>();
// Fix: GetRequiredService inside a scope, then dispose the scopeCaptured closures holding large objects:
byte[] largeBuffer = new byte[10 * 1024 * 1024];
Timer timer = new Timer(_ => Process(largeBuffer), null, 0, 1000);
// largeBuffer is kept alive as long as the timer livesSet largeBuffer = null after you're done if the timer outlives the buffer's usefulness, or restructure so the closure doesn't capture it.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.