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);
}
}
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
└── ...
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
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
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>
For example:
dotnet-counters monitor -p 12345
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
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
The GC periodically reclaims memory.
A problematic application may look more like:
Memory
│
│ /
│ /
│ /
│ /
│___/________________ Time
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>
For example:
dotnet-dump collect -p 12345
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
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
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
Inside the analysis environment, commands such as:
dumpheap -stat
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
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
SomeCacheItemobjects?"
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
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>
Conceptually, you might discover something like:
GC Root
↓
Singleton
↓
CustomerService
↓
List<Customer>
↓
Customer
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();
}
The static field can keep the list alive.
The list keeps the customers alive.
Therefore:
Static Field
↓
List<Customer>
↓
Customer
↓
Customer
↓
Customer
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
Dump 2 — After Load
Customer 50,000
Order 20,000
CacheItem 10,000
Dump 3 — After More Processing
Customer 150,000
Order 40,000
CacheItem 60,000
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);
}
}
Imagine your application processes customers continuously:
for (int i = 0; i < 1_000_000; i++)
{
repository.Add(new Customer());
}
The objects are still referenced by:
_customers
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();
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;
If publisher has a longer lifetime than subscriber, the publisher can keep the subscriber alive.
Conceptually:
Long-lived Publisher
│
│ event
▼
Short-lived Subscriber
If the subscription is never removed:
publisher.SomeEvent -= subscriber.HandleEvent;
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();
If every request adds another item:
_cache[key] = value;
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);
}
}
If the singleton continuously stores request-specific objects:
Application Lifetime
│
▼
Singleton
│
▼
RequestData
RequestData
RequestData
RequestData
...
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);
}
}
}
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);
};
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
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
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 │
└──────────────────────┘
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
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
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
After the fix:
Initial: 300 MB
After load: 450 MB
After more: 470 MB
After more: 460 MB
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
Tools such as:
dotnet-counters
dotnet-dump
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
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)