Back to blog
Backend Systemsadvanced

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.

LearnixoApril 15, 20265 min read
.NETC#MemoryPerformanceGCSpanDiagnostics
Share:𝕏

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:

C#
// 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.

C#
int x = 42;
object boxed = x;          // heap allocation
IComparable c = x;         // also boxing
var list = new List<object> { x }; // boxing on each add

Params arrays:

C#
void Log(string format, params object[] args) { ... }
Log("Value: {0}", 42); // allocates object[] and boxes 42

Span<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.

C#
// 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.

C#
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.

C#
// 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:

C#
// 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.

C#
// 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 connection

Implement IDisposable yourself when your class holds disposable fields:

C#
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:

Bash
dotnet-counters monitor --process-id <PID> System.Runtime

Watch 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:

Bash
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:

C#
// 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:

C#
// This creates a DbContext that never gets disposed
var db = serviceProvider.GetService<AppDbContext>();
// Fix: GetRequiredService inside a scope, then dispose the scope

Captured closures holding large objects:

C#
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 lives

Set 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.