A C# memory leak happens when your application keeps holding onto objects it no longer needs, preventing the garbage collector from reclaiming that memory.

Here’s how we’ll hunt it down and fix it:

Diagnosing the Leak

First, we need to see the leak in action.

  1. Reproduce the Leak: Run your application under conditions that you suspect trigger the leak. Let it run for a while, ideally long enough for the memory usage to climb significantly.

  2. Capture a Heap Dump: We’ll use dotnet-gcdump to grab a snapshot of the managed heap.

    dotnet-gcdump collect -p <PID>
    

    Replace <PID> with the process ID of your running C# application. This command creates a .gcdump file.

  3. Analyze the Heap Dump: Open the .gcdump file in a memory analysis tool like Visual Studio’s Memory Usage tool or PerfView. Look for objects that are unexpectedly growing in count or size over time. Pay attention to the "Retained Size" – this is the memory that would be freed if the object itself was collected.

  4. Trace for Performance Bottlenecks: While the heap dump shows what’s there, dotnet-trace can show why it’s getting there. Capture a trace while the leak is occurring.

    dotnet trace collect -p <PID> --providers Microsoft-Windows-DotNETRuntime --format NetTrace
    

    This creates a .nettrace file. Open this in Visual Studio’s Performance Profiler. Examine the "GC Allocations" and "CPU Usage" to see which methods are allocating the most memory.

Common Causes and Fixes

Here are the usual suspects for C# memory leaks:

  1. Static Collections: A static list, dictionary, or other collection that keeps growing indefinitely without any mechanism to remove items.

    • Diagnosis: In your heap dump analysis, look for large collections with a high retained size, especially if they are static fields.
    • Fix: Implement a bounded collection (e.g., using a ConcurrentQueue with a TryDequeue in a loop) or explicitly clear/remove items from the static collection when they are no longer needed. For example, if you have a static List<MyObject> _cache;, ensure you have _cache.Clear() or _cache.Remove(item) calls.
    • Why it works: By removing references to objects from the static collection, you allow the garbage collector to reclaim their memory.
  2. Event Handlers Not Unsubscribed: When an object subscribes to an event on another object, and the subscribing object is no longer needed but the event source lives on, the subscription keeps the subscriber alive. This is especially common with UI elements or long-lived services.

    • Diagnosis: Look for instances of objects that should have been garbage collected, but are still referenced by event subscriptions. The "GC Root" analysis in your memory tool will often show an event handler as the GC root.
    • Fix: Always unsubscribe from events when the subscribing object is disposed or no longer needs to listen. Use += for subscribing and -= for unsubscribing.
      // In your object's Dispose method or when it's no longer needed:
      myEventSource.MyEvent -= MyEventHandler;
      
    • Why it works: Removing the event subscription breaks the reference chain from the event source to the subscriber, allowing the subscriber to be collected.
  3. Closures Capturing Large Objects: Lambda expressions and anonymous methods can capture variables from their surrounding scope. If a closure lives longer than expected (e.g., it’s attached to a long-lived object or event), it can keep the captured variables alive.

    • Diagnosis: Examine the GC roots in your heap dump. You might see a closure (often represented by an anonymous delegate type) holding a reference to a large object or collection that shouldn’t be alive.
    • Fix: Be mindful of what variables are captured. If possible, pass only necessary data as parameters to the lambda or ensure the closure itself has a limited lifetime. Consider creating local copies of large captured variables inside the lambda if they are only used locally.
      // Potential leak:
      var largeObject = new byte[1024 * 1024];
      var longLivedDelegate = new Action(() => { Console.WriteLine(largeObject.Length); }); // largeObject is captured
      
      // Better:
      var largeObject = new byte[1024 * 1024];
      var localCopy = largeObject; // Copy reference to a local variable
      var shortLivedDelegate = new Action(() => { Console.WriteLine(localCopy.Length); }); // localCopy is captured, but it's just a reference. If localCopy goes out of scope, the large object can be collected.
      
    • Why it works: By limiting the scope of captured variables or ensuring the closure itself is short-lived, you prevent unintended long-term object retention.
  4. Unmanaged Resources Not Disposed: While not strictly a managed memory leak, unmanaged resources (like file handles, network connections, or GDI objects) held by managed objects can prevent the underlying system resources from being released, leading to overall system exhaustion. If these objects also hold managed memory, that memory also stays alive.

    • Diagnosis: Use dotnet-trace with the Microsoft-DotNETRuntime provider and look for high counts of System.Runtime.InteropServices.SafeHandle derivatives or custom unmanaged resource wrappers. Also, check Windows Task Manager for handles.
    • Fix: Implement the IDisposable pattern correctly for classes that manage unmanaged resources. Ensure Dispose() is called, ideally via a using statement or a try-finally block.
      public class MyResourceWrapper : IDisposable
      {
          private IntPtr _handle; // Example unmanaged handle
      
          public MyResourceWrapper()
          {
              // Allocate unmanaged resource and get _handle
          }
      
          public void Dispose()
          {
              Dispose(true);
              GC.SuppressFinalize(this);
          }
      
          protected virtual void Dispose(bool disposing)
          {
              if (_handle != IntPtr.Zero)
              {
                  // Release unmanaged resource associated with _handle
                  _handle = IntPtr.Zero;
              }
              if (disposing)
              {
                  // Dispose managed resources here if any
              }
          }
      
          ~MyResourceWrapper()
          {
              Dispose(false);
          }
      }
      
    • Why it works: Explicitly releasing unmanaged resources frees them up for reuse by the operating system and breaks the chain of managed objects holding onto them.
  5. Caching Without Eviction Policies: Implementing custom caching mechanisms without a strategy to remove old or unused items can lead to unbounded growth.

    • Diagnosis: Similar to static collections, look for cache-like data structures (often dictionaries or specialized cache classes) in your heap dump that are growing excessively.
    • Fix: Use a cache with an eviction policy (e.g., Least Recently Used - LRU, Time-based expiration). Libraries like Microsoft.Extensions.Caching.Memory provide such features. If building your own, implement logic to remove items based on size, age, or access frequency.
    • Why it works: Eviction policies ensure that the cache doesn’t grow indefinitely, removing stale or less-used items to make room for new ones and allowing old items to be garbage collected.
  6. Circular References (Less Common with Modern GC): While the .NET garbage collector is very good at handling circular references, in some complex scenarios, especially involving finalizers or specific interop patterns, they can contribute to leaks if not managed.

    • Diagnosis: This is harder to spot directly. It usually manifests as objects with non-zero retained size that should be collected but aren’t, and their GC roots are complex, potentially involving multiple objects referencing each other.
    • Fix: Break the cycle explicitly when objects are no longer needed. If using finalizers, be extremely careful and consider IDisposable as a primary mechanism. In rare interop cases, IDisposable for both managed and unmanaged sides is crucial.
    • Why it works: Breaking the cycle ensures at least one object in the cycle becomes reachable from a GC root, allowing the entire cycle to be collected.

Next Steps

Once you’ve fixed the identified leaks, the next common problem you’ll encounter is performance degradation due to excessive garbage collection cycles, which often requires a different profiling approach.

Want structured learning?

Take the full Csharp course →