The Complete Guide to .NET Memory Leak Investigation

Close-up of computer memory modules

If you’ve ever babysat a production service, you’ve seen it happen. Memory climbs for hours or days. Response times stretch thin. And then—out of nowhere—an OutOfMemoryException takes the process down. The garbage collector swears it has everything under control, yet leaks still happen. In managed code, a memory leak isn’t some forgotten pointer in the weeds. It’s memory that stayed referenced. Objects that should have been collected but couldn’t be, because something—somewhere—was still holding on.

This guide gives you a repeatable process for hunting .NET memory leaks. I’ll walk through the tools, the diagnostic signals worth your time, and the root-cause patterns I’ve bumped into debugging enterprise apps. No filler. Just the steps and the thinking behind them.

Understanding Managed Memory Leaks

The .NET garbage collector reclaims memory from objects that no root can reach. Roots live in static fields, local variables on active threads, CPU registers, GC handles—any place the runtime considers alive. A managed leak happens when an object graph stays rooted long after the application needs it. The GC can’t touch it because the reachability graph still traces a live path.

The usual suspects fall into a few buckets:

  • Event handler leaks: A short-lived subscriber latches onto a long-lived publisher and never lets go.
  • Static collections that grow without limit: Caches, lookup tables, and lists that add entries but never evict anything.
  • Thread-local storage or thread-static fields: Data tied to a thread that outlives its purpose.
  • Unmanaged resources handled poorly: Finalizers that never fire because the object is still referenced somewhere.
  • Large Object Heap (LOH) fragmentation: Not a true leak, but repeated allocations of big temporary arrays can chew through the address space.

Initial Triage: Memory Counters and Trends

Before you attach a debugger, look at the process from the outside. In a production incident, the question is simple: steady climb, or sudden spike? On Windows, Performance Monitor (perfmon) works; cross-platform, reach for dotnet-counters.

dotnet-counters monitor --process-id [PID] --counters System.Runtime

Keep an eye on these:

  • GC Heap Size (MB): Total size across generations 0, 1, 2, and the LOH. If the number only goes up, with no meaningful dips, you probably have a leak.
  • Gen 2 Collections: Frequent gen 2 runs paired with a fat heap tell you the GC is working too hard and losing.
  • Allocated Bytes/sec: Compare the allocation rate to heap growth. High allocation and a heap that won’t shrink? Objects are sticking around.

Server rack with diagnostic indicators

If the heap keeps swelling until the process crashes, grab a memory dump at the last sane moment. On Windows, that’s procdump -ma [PID] when private bytes cross a threshold. On Linux, dotnet-dump collect covers .NET Core 3.0 and later.

Analyzing the Dump: The SOS Debugging Extension

Load your dump into WinDbg on Windows or dotnet-dump analyze cross-platform. The Son of Strike (SOS) extension gives you the commands that matter. First, load SOS:

.loadby sos coreclr   // for .NET Core
.loadby sos clr       // for .NET Framework

Assessing the Overall Heap

Run !dumpheap -stat. You’ll get a table of types sorted by total memory consumed. Look for numbers that don’t make sense. If System.String is sitting on 800 MB and you expect a few hundred strings, start there. Zero in on a type with:

!dumpheap -mt [MethodTable]

That lists every instance. To understand why a particular object is still alive, use !gcroot [address]. The output traces every reference chain from roots to your target. A chain that dead-ends in a static field or an event delegate? You just found your leak.

Examining Finalization Queue

Objects with finalizers that sit around too long can bloat memory. Check the queue with !finalizequeue. A fat count of objects waiting for finalization usually means the finalizer thread is blocked, or those objects are still reachable.

Code debugging interface on a monitor

Common Leak Patterns and Their Signatures

Event Handler Retention

This one is a classic. A class hooks into a static event or a long-lived instance event and forgets to unhook. In a dump, the subscriber type hangs around because an event delegate chain still references it. When you run !gcroot, look for EventHandler or Action references. The publisher’s _invocationList will hold a reference to the subscriber. The fix? Unsubscribe in Dispose or when the subscriber’s job is done.

Unbounded Caches

A ConcurrentDictionary or MemoryCache that adds entries but never expires them. In the dump, the cache type shows a huge item count. Use !dumpheap -type [CacheType] and then poke at the internal collection. Production code should lean on MemoryCache with absolute or sliding expiration—or a bounded LRU cache—to keep a lid on things.

Thread-Local Leaks

Thread-local storage (TLS) can cling to objects for as long as a thread lives. If you use ThreadStatic attributes or ThreadLocal<T> without cleaning up, thread pool threads may pile up data across work items. The dump shows high memory per thread. Use !threads and then !clrstack to see what each thread is dragging along.

Large Object Heap Fragmentation

LOH allocations—objects 85,000 bytes or larger—aren’t compacted by default. Repeated allocation of big temporary arrays creates free blocks that can’t satisfy new requests, and you get out-of-memory errors even when total free memory looks okay. Check LOH size with !eeheap -gc. If the LOH is large but fragmented, think about pooling large arrays or switching to ArrayPool<T>.

Using PerfView for Production Profiling

Sometimes dump analysis won’t reveal where the leak started, especially when you need to watch behavior over time. That’s where PerfView comes in. It collects ETW traces with very low overhead. The GCHeapSurvival view shows objects that survived across collections. Filter by type and hunt for objects whose count rises and never drops. The reference graph tab shows the path to roots. This approach shines for leaks that build up over days.

Prevention: Design and Testing Practices

Leak prevention starts in code review. Roslyn analyzers can flag suspicious patterns: event subscriptions without matching unsubscriptions, static mutable collections, missing IDisposable implementations. Bake memory leak tests into your CI pipeline. A simple test that allocates a component, releases it, forces a full GC, and asserts the component was collected will catch regressions before they hit production.

For web apps, watch the dotnet/aspnetcore diagnostic metrics. The runtime exposes gc-heap-size and threadpool-thread-count through the /metrics endpoint. Trigger an alert when heap size stays above a known baseline after a standard workload.

Frequently Asked Questions

Why does the garbage collector not prevent all memory leaks?

The GC only collects what’s unreachable. A .NET memory leak means objects are still reachable from roots—static fields, active threads, event delegates—even though the application doesn’t need them anymore. The GC can’t read your mind; it just follows the reachability graph.

What is the fastest way to identify the leaking type in a memory dump?

Run !dumpheap -stat and scan for types with abnormal total size or instance counts. Then use !dumpheap -mt [MT] to list instances and !gcroot on a few representative addresses. More often than not, that points you straight at the root cause in minutes.

How can I differentiate between a genuine leak and high memory usage due to caching?

Watch whether memory usage levels off. A cache grows to its configured limit and then stays put. A leak keeps climbing until the process crashes. In a dump, inspect the cache’s expiration policy and item count. If items never expire and the count rises without bound, you’re looking at a leak.

Is there a way to detect leaks in production without taking a full memory dump?

Absolutely. Lightweight ETW tracing with PerfView or the dotnet-trace tool collects GC heap snapshots and object reference graphs with very low overhead. The GCHeapSurvival view shows objects that persist across collections, letting you spot leaks over time.