DEV Community

Cover image for How to Find and Fix Memory Leaks in .NET Projects
Ravi Vishwakarma
Ravi Vishwakarma

Posted on

How to Find and Fix Memory Leaks in .NET Projects

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

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.

Instead, it often means:

Objects that are no longer logically needed are still reachable through references, so the GC cannot collect them.

For example:

public class CustomerService
{
    private static readonly List<Customer> _customers = new();

    public void AddCustomer(Customer customer)
    {
        _customers.Add(customer);
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

Static Collection
      │
      ├── Customer
      ├── Customer
      ├── Customer
      ├── Customer
      └── ...
Enter fullscreen mode Exit fullscreen mode

The GC sees that the objects are still reachable.

Therefore:

Object is referenced
        ↓
GC considers it alive
        ↓
Object is NOT collected
        ↓
Memory usage continues growing
Enter fullscreen mode Exit fullscreen mode

Over time, this can lead to high memory consumption and eventually an OutOfMemoryException.


The Memory-Leak Investigation Workflow

A useful investigation can be divided into six steps:

Monitor
   ↓
Capture
   ↓
Analyze
   ↓
Find Roots
   ↓
Fix
   ↓
Verify
Enter fullscreen mode Exit fullscreen mode

Let's go through each step.


1. Confirm That It's Actually a Memory Leak

The first mistake developers often make is assuming:

“Memory usage is high, therefore there is a memory leak.”

That's not necessarily true.

.NET applications naturally use memory for:

  • JIT compilation
  • object allocations
  • caches
  • thread stacks
  • runtime structures
  • temporary objects
  • garbage collection
  • native allocations

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?

Monitor the application

One useful tool is:

dotnet-counters monitor -p <PID>
Enter fullscreen mode Exit fullscreen mode

For example:

dotnet-counters monitor -p 12345
Enter fullscreen mode Exit fullscreen mode

You can monitor metrics related to:

  • GC activity
  • allocation rate
  • managed heap size
  • CPU
  • exceptions
  • thread pool activity

Suppose your application processes 1,000 requests.

You observe:

Initial memory:    300 MB

After 1,000 requests:   420 MB
After 2,000 requests:   550 MB
After 3,000 requests:   690 MB
After 4,000 requests:   830 MB
Enter fullscreen mode Exit fullscreen mode

If memory consistently grows and does not return to a stable range after garbage collection, you have a reason to investigate further.

Important distinction

A healthy application can have:

Memory
  │       ┌─────┐
  │   ┌───┘     └───┐
  │───┘             └───
  └──────────────────────── Time
Enter fullscreen mode Exit fullscreen mode

The GC periodically reclaims memory.

A problematic application may look more like:

Memory
  │
  │             /
  │          /
  │       /
  │    /
  │___/________________ Time
Enter fullscreen mode Exit fullscreen mode

The second pattern is much more suspicious.


2. Capture a Memory Dump

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.

You can use:

dotnet-dump collect -p <PID>
Enter fullscreen mode Exit fullscreen mode

For example:

dotnet-dump collect -p 12345
Enter fullscreen mode Exit fullscreen mode

This produces a dump file that can be analyzed later.

Think of a memory dump as taking a photograph of your application's memory:

Running Application
        │
        ▼
   Memory Dump
        │
        ▼
Objects + References + Runtime State
Enter fullscreen mode Exit fullscreen mode

For a production application, it's often useful to capture dumps at different points in time.

For example:

Dump 1 → Baseline
Dump 2 → After application load
Dump 3 → After several hours
Enter fullscreen mode Exit fullscreen mode

This allows you to compare what changed.


3. Find Out What's Using the Memory

After obtaining a dump, you can analyze it with dotnet-dump.

Start the analyzer:

dotnet-dump analyze dump.dmp
Enter fullscreen mode Exit fullscreen mode

Inside the analysis environment, commands such as:

dumpheap -stat
Enter fullscreen mode Exit fullscreen mode

can help you understand what types are consuming memory.

You might see something conceptually like:

Type                         Count          Size

System.String                1,500,000      120 MB
MyApp.Customer               500,000         40 MB
MyApp.Order                  300,000         35 MB
MyApp.SomeCacheItem          250,000         30 MB
Enter fullscreen mode Exit fullscreen mode

Now you have a much better question to ask.

Instead of:

"Why is my application using 500 MB?"

You can ask:

"Why are there 250,000 SomeCacheItem objects?"

That's a much more useful debugging direction.


4. Finding the Object That's Keeping Memory Alive

Finding a large object type is only half the investigation.

Suppose you discover:

MyApp.Customer
Count: 500,000
Enter fullscreen mode Exit fullscreen mode

The next question is:

Why haven't these customers been collected?

This is where GC roots become extremely important.

You can investigate an object's reference chain using:

gcroot <address>
Enter fullscreen mode Exit fullscreen mode

Conceptually, you might discover something like:

GC Root
  ↓
Singleton
  ↓
CustomerService
  ↓
List<Customer>
  ↓
Customer
Enter fullscreen mode Exit fullscreen mode

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.


Understanding GC Roots

A GC root is an object or reference from which the GC can determine that other objects are still reachable.

Examples include:

  • static fields
  • active threads
  • local variables on active stacks
  • runtime handles
  • other GC roots

Consider:

public class CustomerCache
{
    private static readonly List<Customer> Customers = new();
}
Enter fullscreen mode Exit fullscreen mode

The static field can keep the list alive.

The list keeps the customers alive.

Therefore:

Static Field
     ↓
List<Customer>
     ↓
Customer
     ↓
Customer
     ↓
Customer
Enter fullscreen mode Exit fullscreen mode

The customers remain reachable.

This is why simply finding a large object isn't enough.

You need to understand:

Who is keeping this object alive?


5. Compare Memory Over Time

One memory dump gives you a snapshot.

Two or more dumps can tell you a story.

For example:

Dump 1 — Before Load

Customer       10,000
Order           5,000
CacheItem       2,000
Enter fullscreen mode Exit fullscreen mode

Dump 2 — After Load

Customer       50,000
Order          20,000
CacheItem      10,000
Enter fullscreen mode Exit fullscreen mode

Dump 3 — After More Processing

Customer      150,000
Order          40,000
CacheItem      60,000
Enter fullscreen mode Exit fullscreen mode

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.


A Practical Example: Static Collection Leak

Consider this code:

public class CustomerRepository
{
    private static readonly List<Customer> _customers = new();

    public void Add(Customer customer)
    {
        _customers.Add(customer);
    }
}
Enter fullscreen mode Exit fullscreen mode

Imagine your application processes customers continuously:

for (int i = 0; i < 1_000_000; i++)
{
    repository.Add(new Customer());
}
Enter fullscreen mode Exit fullscreen mode

The objects are still referenced by:

_customers
Enter fullscreen mode Exit fullscreen mode

Therefore the GC cannot remove them.

The memory usage can continue increasing.

A possible solution

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.


Common Causes of Memory Leaks in .NET

Several patterns repeatedly appear during memory investigations.

1. Static Collections

This is one of the easiest ways to accidentally retain objects.

private static readonly List<MyObject> _items = new();
Enter fullscreen mode Exit fullscreen mode

If _items continually grows, objects inside it remain reachable.

Consider whether the data really needs application-wide lifetime.


2. Event Subscriptions

Events can also cause unexpected object retention.

For example:

publisher.SomeEvent += subscriber.HandleEvent;
Enter fullscreen mode Exit fullscreen mode

If publisher has a longer lifetime than subscriber, the publisher can keep the subscriber alive.

Conceptually:

Long-lived Publisher
        │
        │ event
        ▼
Short-lived Subscriber
Enter fullscreen mode Exit fullscreen mode

If the subscription is never removed:

publisher.SomeEvent -= subscriber.HandleEvent;
Enter fullscreen mode Exit fullscreen mode

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.


3. Unbounded Caches

Caching isn't automatically a memory leak.

But an unbounded cache can become a serious memory problem.

For example:

private readonly Dictionary<string, object> _cache = new();
Enter fullscreen mode Exit fullscreen mode

If every request adds another item:

_cache[key] = value;
Enter fullscreen mode Exit fullscreen mode

and nothing is ever removed, the cache can grow indefinitely.

A cache should normally have an appropriate strategy such as:

  • expiration
  • maximum size
  • eviction
  • invalidation
  • appropriate lifetime

The correct strategy depends on the application's requirements.


4. Singleton References

Singletons live for a very long time—often for the lifetime of the application.

That makes them particularly important during memory-leak investigations.

Imagine:

public class MySingleton
{
    private readonly List<RequestData> _requests = new();

    public void Add(RequestData data)
    {
        _requests.Add(data);
    }
}
Enter fullscreen mode Exit fullscreen mode

If the singleton continuously stores request-specific objects:

Application Lifetime
       │
       ▼
   Singleton
       │
       ▼
RequestData
RequestData
RequestData
RequestData
...
Enter fullscreen mode Exit fullscreen mode

those request objects may effectively have application lifetime too.


5. Background Services

Background services can also accidentally retain data.

For example:

public class Worker : BackgroundService
{
    private readonly List<object> _items = new();

    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _items.Add(GetData());

            await Task.Delay(1000, stoppingToken);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The loop continuously adds objects to _items.

Unless the collection is periodically cleaned or bounded, memory consumption can keep increasing.

Background services deserve special attention because they can run continuously for hours, days, or months.


6. Closures and Long-Lived Delegates

Closures can sometimes retain objects longer than expected.

For example:

var largeObject = CreateLargeObject();

someLongLivedCallback += () =>
{
    Process(largeObject);
};
Enter fullscreen mode Exit fullscreen mode

The callback captures largeObject.

If someLongLivedCallback lives for a very long time, the captured object may also remain alive.

The important question is always:

What reference is preventing the object from becoming unreachable?


Memory Leak vs High Memory Usage

These two concepts shouldn't be confused.

High memory usage

An application might legitimately use a lot of memory:

Large workload
     ↓
More objects
     ↓
Higher memory usage
Enter fullscreen mode Exit fullscreen mode

But after the workload finishes and objects become unreachable, GC can reclaim them.

Memory leak

With a leak:

Objects become logically unnecessary
             ↓
But references still exist
             ↓
Objects remain reachable
             ↓
GC cannot collect them
             ↓
Memory continues accumulating
Enter fullscreen mode Exit fullscreen mode

That's why simply looking at the process's memory number isn't enough.


A Better Way to Think About Memory Leaks

Instead of asking:

"Why is memory usage high?"

ask these questions:

1. What objects are consuming the memory?

Use heap analysis.

2. Are these objects expected to exist?

Determine whether the object population is legitimate.

3. Why are there so many instances?

Look at allocation patterns and application behavior.

4. Why aren't they being collected?

Investigate references and GC roots.

5. What is keeping them alive?

Follow the reference chain.

6. Is that reference intentional?

This is the key question.


The Complete Investigation Process

Here's a practical workflow you can use in real .NET projects:

┌──────────────────────┐
│ 1. Monitor           │
│ dotnet-counters      │
└──────────┬───────────┘
           ↓
┌──────────────────────┐
│ 2. Confirm Growth    │
│ Is memory increasing │
│ over time?           │
└──────────┬───────────┘
           ↓
┌──────────────────────┐
│ 3. Capture Dump      │
│ dotnet-dump collect  │
└──────────┬───────────┘
           ↓
┌──────────────────────┐
│ 4. Analyze Heap      │
│ dumpheap -stat       │
└──────────┬───────────┘
           ↓
┌──────────────────────┐
│ 5. Find References   │
│ gcroot <address>     │
└──────────┬───────────┘
           ↓
┌──────────────────────┐
│ 6. Identify Cause    │
│ Static? Event?       │
│ Cache? Singleton?    │
└──────────┬───────────┘
           ↓
┌──────────────────────┐
│ 7. Fix               │
│ Remove unintended    │
│ references           │
└──────────┬───────────┘
           ↓
┌──────────────────────┐
│ 8. Verify            │
│ Monitor again        │
│ and compare dumps    │
└──────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Don't Just Look at the Biggest Object

This is an important lesson.

Suppose your dump shows:

String        200 MB
Customer       80 MB
Order          70 MB
CacheItem      50 MB
Enter fullscreen mode Exit fullscreen mode

It may be tempting to immediately investigate String.

But strings could simply be legitimate application data.

Instead, ask:

Which object population is unexpectedly growing?

For example, if CacheItem grows from:

5 MB → 15 MB → 50 MB → 100 MB
Enter fullscreen mode Exit fullscreen mode

while the workload remains similar, that is much more interesting.

Memory debugging is therefore not just about finding the largest object.

It's about finding the unexpectedly retained object.


Fixing the Leak Is Only Half the Job

After making a change, don't immediately assume the problem is solved.

Repeat the same workload.

For example:

Before Fix

Initial:       300 MB
After load:    700 MB
After more:    1.1 GB
Enter fullscreen mode Exit fullscreen mode

After the fix:

Initial:       300 MB
After load:    450 MB
After more:    470 MB
After more:    460 MB
Enter fullscreen mode Exit fullscreen mode

The second pattern suggests that memory is reaching a more stable range.

You can also take another dump and compare the object population.


Practical Checklist

When investigating a suspected .NET memory leak, use this checklist:

Confirm

  • Is memory actually growing over time?
  • Does the growth happen under a repeatable workload?
  • Is the application experiencing GC pressure?
  • Is the growth expected for the workload?

Capture

  • Capture a memory dump.
  • Record when the dump was taken.
  • Capture a baseline dump.
  • Capture another dump after the memory grows.

Analyze

  • Inspect heap statistics.
  • Find unexpectedly large object populations.
  • Identify objects that continue accumulating.
  • Investigate their reference chains.

Find the Root

Look for:

  • Static collections
  • Event subscriptions
  • Unbounded caches
  • Singleton references
  • Background services
  • Long-lived delegates
  • Long-lived tasks
  • Unexpected object ownership

Verify

  • Apply the fix.
  • Run the same workload again.
  • Monitor memory.
  • Capture another dump if necessary.
  • Compare object counts over time.

Final Takeaway

Finding a memory leak in .NET isn't simply about asking:

"Why is memory usage high?"

A better investigation is:

"Which objects are growing, why are they still reachable, and what is keeping them alive?"

The general workflow is:

Monitor
   ↓
Capture
   ↓
Analyze
   ↓
Find Roots
   ↓
Fix
   ↓
Verify
Enter fullscreen mode Exit fullscreen mode

Tools such as:

dotnet-counters
dotnet-dump
Enter fullscreen mode Exit fullscreen mode

can help you move from a vague symptom—"our application is using too much memory"—to a concrete explanation such as:

Static Collection
       ↓
List<Customer>
       ↓
500,000 Customers
       ↓
Objects remain reachable
       ↓
GC cannot collect them
Enter fullscreen mode Exit fullscreen mode

Once you understand the reference chain, memory leaks become much less mysterious.

The key principle is simple:

Don't ask only what is using memory. Ask what is keeping that memory alive.

Top comments (0)