
How to Find and Fix Memory Leaks in .NET Projects
Memory leaks in .NET applications can be confusing.
Spots 

Memory leaks in .NET applications can be confusing.

After all, .NET has a Garbage Collector (GC) that automatically removes objects that are no longer needed. So developers often ask: “If .NET has garbage collection, how can a memory leak happen?” “If .NET has garbage collection, how can a memory leak happen?” The answer is that the Garbage Collector can only collect objects that are no longer reachable.
If an object is still referenced somewhere in your application—even accidentally—the GC considers it alive and cannot reclaim its memory.
This article presents a practical workflow for finding memory leaks in .NET applications using tools such as dotnet-counters, dotnet-dump, and dotnet-gcdump. What Is a Memory Leak in .NET? A memory leak in .NET usually doesn't mean that memory is completely lost forever. Objects that are no longer logically needed are still reachable through references, so the GC cannot collect them. Objects that are no longer logically needed are still reachable through references, so the GC cannot collect them. If customers are continuously added to this static collection and never removed, the list keeps references to them. Even if the application no longer needs those customers: The GC sees that the objects are still reachable. Over time, this can lead to high memory consumption and eventually an OutOfMemoryException. A useful investigation can be divided into six steps: Let's go through each step.
The first mistake developers often make is assuming: “Memory usage is high, therefore there is a memory leak.” “Memory usage is high, therefore there is a memory leak.” That's not necessarily true. .NET applications naturally use memory for: Memory usage increasing temporarily doesn't automatically indicate a leak. The important question is: Does memory continue growing when the application performs the same workload repeatedly? Does memory continue growing when the application performs the same workload repeatedly? You can monitor metrics related to: Suppose your application processes 1,000 requests.
If memory consistently grows and does not return to a stable range after garbage collection, you have a reason to investigate further. A healthy application can have: The GC periodically reclaims memory. A problematic application may look more like: The second pattern is much more suspicious. Once you've confirmed that memory is behaving suspiciously, the next step is to capture the state of the process. A memory dump gives you a snapshot of what objects exist in memory and how they are connected. This produces a dump file that can be analyzed later. Think of a memory dump as taking a photograph of your application's memory: For a production application, it's often useful to capture dumps at different points in time. This allows you to compare what changed. After obtaining a dump, you can analyze it with dotnet-dump.
Inside the analysis environment, commands such as: can help you understand what types are consuming memory. You might see something conceptually like: Now you have a much better question to ask. "Why is my application using 500 MB?" "Why is my application using 500 MB?" "Why are there 250,000 SomeCacheItem objects?" "Why are there 250,000 SomeCacheItem objects?" That's a much more useful debugging direction. Finding a large object type is only half the investigation. Why haven't these customers been collected? Why haven't these customers been collected? This is where GC roots become extremely important. You can investigate an object's reference chain using: Conceptually, you might discover something like: Now you've found the actual problem. The customer object isn't being collected because a long-lived object still holds a reference to it.
A GC root is an object or reference from which the GC can determine that other objects are still reachable. local variables on active stacks The static field can keep the list alive. The list keeps the customers alive. The customers remain reachable. This is why simply finding a large object isn't enough. Who is keeping this object alive? Who is keeping this object alive? One memory dump gives you a snapshot. Two or more dumps can tell you a story. Dump 3 — After More Processing The important observation isn't just that memory increased. It's that certain object populations are continuously accumulating. This gives you a much stronger lead. Imagine your application processes customers continuously: The objects are still referenced by: Therefore the GC cannot remove them. The memory usage can continue increasing.
If the collection isn't supposed to retain every customer forever, change the lifecycle. For example, use a bounded cache, remove entries when appropriate, or avoid storing objects globally. The exact fix depends on why the collection exists. Several patterns repeatedly appear during memory investigations. This is one of the easiest ways to accidentally retain objects. If _items continually grows, objects inside it remain reachable. Consider whether the data really needs application-wide lifetime. Events can also cause unexpected object retention. If publisher has a longer lifetime than subscriber, the publisher can keep the subscriber alive. If the subscription is never removed: the subscriber may remain reachable longer than intended. This is especially important when objects subscribe to events but have shorter lifetimes than the event publisher.
Caching isn't automatically a memory leak. But an unbounded cache can become a serious memory problem. If every request adds another item: and nothing is ever removed, the cache can grow indefinitely. A cache should normally have an appropriate strategy such as: The correct strategy depends on the application's requirements. Singletons live for a very long time—often for the lifetime of the application. That makes them particularly important during memory-leak investigations.
Memory leaks in .NET applications can be confusing.
