Telling GC Lag from a Memory Leak in .NET — A Practical Procedure for Observing, Comparing, and Proving Memory Growth
· Updated: · Go Komura · .NET, C#, GC, Memory Leak, Diagnostics, dotnet-counters, dotnet-dump, Operations, Legacy Asset Reuse
Revision history (1 updates, last updated Sep 1, 2026)
A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.
- Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614661)
- First published
Cite this article(DOI: 10.5281/zenodo.21614660)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). Telling GC Lag from a Memory Leak in .NET — A Practical Procedure for Observing, Comparing, and Proving Memory Growth. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614660 https://comcomponent.com/en/blog/2026/06/09/000-dotnet-gc-or-memory-leak/
- DOI (latest version)
- 10.5281/zenodo.21614660
- DOI (this version)
- 10.5281/zenodo.22220521
1. What to Understand First
When operating a .NET application, you sometimes see memory usage creep steadily upward.
Task Manager or top shows the process’s memory growing.
The container’s memory usage is growing too.
The Working Set or RSS graph in monitoring trends up and to the right.
Seeing this, the reflex is to think “memory leak.” But in .NET, a process’s memory growing and a memory leak are not the same thing.
.NET has garbage collection. Memory is not returned to the OS the instant an object becomes unnecessary. The GC operates based on allocation patterns, heap thresholds, memory pressure, generations, and workload conditions.
As a result, states like these occur:
- Objects that are no longer needed have not been collected yet
- The GC has run, but the process’s Working Set does not drop right away
- Memory grows once due to first access, JIT, caches, or connection pools, then stabilizes
- The managed heap is stable, but native memory, threads, sockets, or an image processing library is growing
- Objects that genuinely should be unnecessary are still being referenced from somewhere
This article covers how to identify the last case — the genuine leak. What you need to look at is not raw memory usage, but these three things:
- Is the memory that survives GC growing?
- Which types are growing?
- Who is referencing those objects?
A .NET memory leak investigation is the work of going beyond “memory is growing” to “objects of this type are growing, and they are still referenced from this root.”
The code in this article is published on GitHub as a complete buildable, runnable sample set (a library of typical leak patterns, a demo observing the difference between GC lag and survivors, and unit tests verifying retention and collection with WeakReference).
dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (23 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
2. First, Agree on What “Memory Leak” Means
A memory leak in .NET is not only the C/C++ shape of “forgot to free allocated memory.”
In managed code, the GC collects objects. Whether the GC can collect an object is determined by whether any reachable reference to it still exists.
In other words, the typical .NET memory leak is this:
An object is no longer needed by the business logic, yet it is still referenced — from a static field, a cache, an event, a Timer, a collection, a DI lifetime, an async context — so from the GC’s point of view it still looks in use.
The GC is smart, but it cannot know whether something is needed by the business. If it is referenced, the GC judges it alive.
That is why, in .NET, it is clearer to think of “unintended retention” rather than “leak.”
On the other hand, the following states do not immediately indicate a memory leak:
| State | Why it is not necessarily a leak |
|---|---|
| Working Set / RSS is growing | This is memory the OS has assigned to the process; it does not match the amount of live objects on the managed heap |
| Total Allocated is growing | This is the cumulative amount allocated since startup; it grows whenever the app does anything |
| GC Heap spikes momentarily | Uncollected objects may simply remain until the next GC |
| Memory grows right after startup | Commonly caused by JIT, type loading, initial caches, connection pools, template expansion |
| LOH is large | May reflect reuse of large arrays and buffers, fragmentation, or pooling strategy |
| Memory does not go down | Even after the GC collects, the process does not necessarily return memory to the OS immediately |
Conversely, the more of the following you observe together, the stronger the suspicion of a memory leak:
| Observation | Meaning |
|---|---|
| The post-GC heap grows every time you repeat the same operation | Surviving objects are increasing |
| Gen 2 or LOH size keeps growing | Long-lived objects, or large objects, are sticking around |
| The same type’s Count / Size grows across multiple dumps | You can identify the growing type |
gcroot shows references from statics, events, caches, or long-lived services |
You can explain why the GC cannot collect |
| Memory does not return after stopping load, even after enough time or a verification GC | Likely not just temporary allocation |
3. Distinguish Which “Memory” You Are Looking At
The first source of confusion in a memory investigation is that different memory metrics get mixed up. They are all “memory,” but they mean different things.
| Metric | What it measures | How to read it |
|---|---|---|
| Working Set / RSS | The process’s pages resident in physical memory | The OS’s view of memory; not the GC heap itself |
| Private Bytes / Commit | Committed memory privately owned by the process | Also includes native memory, stacks, JIT code, GC segments |
| GC Heap Size | The amount of objects on the managed heap | The entry point for looking at GC-managed memory in .NET |
| Total Allocated | Cumulative amount allocated since startup | Always grows; never use it alone to judge a leak |
| Gen 0 / Gen 1 / Gen 2 | Heaps by generation | What remains in Gen 2 is long-lived |
| LOH | The heap for large objects of 85,000 bytes or more | Tends to grow with large arrays, strings, buffers |
| POH | The heap for pinned objects | A clue for the effects of native interop and pinning |
| Finalization Queue | Objects awaiting finalization | A clue for missed Dispose calls or a clogged finalizer |
Drawing which metric covers which region gives this picture.
flowchart TB
PROC["Memory used by the process"] --> MANAGED["Managed side<br/>the range visible in GC Heap Size"]
PROC --> NATIVE["Native side<br/>the range that never shows up in GC Heap Size"]
MANAGED --> G01["Gen 0 / Gen 1<br/>short-lived allocations"]
MANAGED --> G2["Gen 2<br/>long-lived objects that survived"]
MANAGED --> LOH["LOH<br/>large objects of 85,000 bytes or more"]
MANAGED --> POH["POH<br/>pinned objects"]
NATIVE --> STK["Thread stacks"]
NATIVE --> JITC["JIT-compiled code, loaded assemblies"]
NATIVE --> INTEROP["P/Invoke, COM, external library buffers"]
MANAGED -.counted in.-> COMMIT["Private Bytes / Commit<br/>committed memory privately owned by the process"]
NATIVE -.counted in.-> COMMIT
COMMIT -.only the part resident in physical memory.-> WS["Working Set / RSS"]
There are two points to take away from this diagram:
- What
dumpheapshows you is the managed side only. If the native side is what is growing, staring at the heap will never produce a culprit. - Working Set and Commit are not nested inside one another. Memory can be committed and still absent from the Working Set because it is not resident in physical memory, and conversely pages that are not private, such as those of a shared library, can be counted in the Working Set. That is why “Working Set is not dropping, so the GC has not collected” does not follow.
You do not need to examine all of these in detail from the start. First, break the question down:
The process's memory is growing
↓
Is the managed heap also growing?
↓
Is the amount that survives GC growing?
↓
Which types are growing?
↓
Who is referencing them?
Following this order makes it much harder to confuse “memory growth that merely looks bad” with “a real leak.”
4. The Decision Flow
In practice, the following flow makes the triage easier:
1. Define the reproduction conditions
- Which API, screen, job, or batch makes it grow
- How many executions make it grow
- What happens when the load stops
2. Look at trends with dotnet-counters
- Working Set
- GC Heap
- Gen 2 / LOH
- Total Allocated
- GC counts
3. Compare over time
- Right after startup
- After warm-up
- Under load
- After load stops
- After repeating the same operation N times
4. Take two or more dumps
- before
- after
- if possible, also after load stops
5. Find the growing types
- dumpheap -stat
- gcdump report
- Visual Studio / PerfView
6. Check who references them
- gcroot
- gchandles
- finalizequeue
7. Make a determination
- Waiting on GC
- Normal cache growth
- Managed memory leak
- Native memory problem
- LOH fragmentation or temporary large allocations
What matters is not judging from a single number. A memory leak is “a trend of continuous growth,” so compare over time under the same conditions, not at a single point.
5. Tools to Use
This article mainly uses these tools:
| Tool | When to use it |
|---|---|
dotnet-counters |
View GC and Working Set trends in a running process |
dotnet-gcdump |
Take lightweight statistics of live managed objects |
dotnet-dump |
Inspect the heap in detail and trace references with dumpheap and gcroot |
| Visual Studio Memory Usage | When you want GUI comparison on Windows |
| PerfView | When you want deep GC / heap / trace analysis on Windows |
dotnet-trace |
When you want to follow allocations and GC events over time |
First, install the CLI tools.
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-trace
If they are already installed, update them.
dotnet tool update --global dotnet-counters
dotnet tool update --global dotnet-dump
dotnet tool update --global dotnet-gcdump
dotnet tool update --global dotnet-trace
Find the process to investigate.
dotnet-counters ps
In the examples that follow, the target process ID is written as <PID>.
On Linux, macOS, and in container environments, the diagnostic tools must run as the same user as the target process. Depending on the environment, you may also be affected by TMPDIR, the diagnostic port, and the container’s PID namespace.
When working against production, do not jump straight to taking dumps — first check the load and impact in a test environment.
5.1 When the target runs on .NET Framework 4.x
dotnet-counters, dotnet-dump, and dotnet-gcdump rely on the diagnostic facilities that the runtime has had since .NET Core 3.0. If the application under investigation is .NET Framework 4.x, none of them apply. Maintaining an existing Windows Forms, WPF, or ASP.NET application puts you in this situation.
Map the replacements like this.
| Tool used in this article | Equivalent on .NET Framework 4.x |
|---|---|
Watch trends with dotnet-counters |
Performance Monitor, or Get-Counter against the counters in the .NET CLR Memory category |
Compare type statistics with dotnet-gcdump |
PerfView GC heap dumps, or snapshot comparison in the Visual Studio Memory Usage tool |
Capture a dump with dotnet-dump collect |
ProcDump, Create dump file in Task Manager, or dump generation configured through Windows Error Reporting |
dumpheap / gcroot in dotnet-dump analyze |
Run .loadby sos clr in WinDbg, then use !dumpheap -stat and !gcroot |
Follow allocations with dotnet-trace |
PerfView GC heap allocation collection |
The approach is exactly the same three-stage sequence: watch the trend, compare type statistics twice, trace the references. Only the tools change.
If you are working in Performance Monitor, these are the counters in the .NET CLR Memory category to look at first.
| Counter | What it measures |
|---|---|
# Bytes in all Heaps |
The total of Gen 1, Gen 2, and the LOH. Close to what this article calls GC Heap Size |
Gen 2 heap size |
The current byte count of Gen 2. If it keeps growing, suspect a leak |
Large Object Heap size |
The current size of the LOH |
# Gen 2 Collections |
The number of full GCs. A sharp rise suggests excessive allocation |
% Time in GC |
The share of the most recent GC cycle spent in the GC |
Finalization Survivors |
The number of objects that survived because they are awaiting finalization. A clue for missed Dispose calls |
# Total committed Bytes |
The amount of virtual memory the GC has committed |
Counter names may be displayed in the local language on a localized Windows installation. If you cannot find one, do not only search for the English name — also look for the localized category name.
The entry point for analysis in WinDbg is loading SOS.
0:000> .loadby sos clr
0:000> !dumpheap -stat
0:000> !gcroot <OBJECT_ADDRESS>
On .NET Framework, SOS commands are prefixed with !. The dumpheap -stat and gcroot commands that appear from section 9 onward work exactly the same way once you read them as !dumpheap -stat and !gcroot.
6. Start with Trends via dotnet-counters
The first thing to look at is not a detailed dump, but the trend.
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime
The output varies slightly by .NET version.
On .NET 9 and later, counters may appear under the System.Runtime Meter names; on .NET 8 and earlier, under the traditional EventCounter names.
The main items to watch:
| Item | What to look at |
|---|---|
dotnet.process.memory.working_set |
The process’s resident memory from the OS’s perspective |
dotnet.gc.last_collection.heap.size |
Per-generation heap sizes after the most recent GC |
dotnet.gc.last_collection.memory.committed_size |
The amount of memory the GC has committed |
dotnet.gc.heap.total_allocated |
Cumulative allocations since startup |
dotnet.gc.collections |
GC counts by generation |
dotnet.gc.pause.time |
Cumulative GC pause time |
You can also narrow the monitoring to specific counters:
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime[dotnet.process.memory.working_set,dotnet.gc.last_collection.heap.size,dotnet.gc.last_collection.memory.committed_size,dotnet.gc.heap.total_allocated,dotnet.gc.collections]
To review later, save to CSV:
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output counters.csv \
--counters System.Runtime
At this stage, what you want to distinguish are the following patterns.
6.1 Only Total Allocated grows
dotnet.gc.heap.total_allocated is a cumulative value. If the application processes requests, it allocates objects, and even if those objects immediately become garbage and are collected, the cumulative allocation count grows.
So Total Allocated growing on its own does not mean a memory leak. What matters is whether allocations stick around afterward.
Total Allocated: grows
GC Heap Size: fluctuates somewhat but stays stable
Gen 2 / LOH: does not keep growing
In this case, it is not a leak — it is an application that allocates a lot.
The remedy is not a leak fix, but allocation reduction: reusing buffers, reviewing heavy LINQ usage, reducing string creation, revisiting serialization code, and so on.
6.2 Working Set grows but GC Heap stays stable
Sometimes Working Set or RSS grows even though the GC Heap is stable. In this case, it is not necessarily a managed object leak.
Possible factors include:
- JIT-compiled code
- Loaded assemblies
- Thread stacks
- Memory used by native libraries
- Unmanaged memory such as
Marshal.AllocHGlobal - Native-side buffers in imaging, compression, cryptography, DB drivers
- Internal buffers for sockets, file handles, SSL, HTTP/2, gRPC
- The OS simply not reclaiming physical pages from the process yet
In this state, you can stare at dumpheap all day and not find a culprit.
The rule of thumb:
Working Set / RSS: grows
GC Heap Size: stable
Gen 2 / LOH: stable
In this case, suspect native memory, handles, thread count, sockets, and external libraries rather than a .NET managed heap leak.
Do not stop at dotnet-counters — also look at OS tools, container metrics, handle counts, thread counts, the native heap, and external library metrics.
6.3 GC Heap grows under load but returns after load stops
It is natural for the GC Heap to grow under load.
Many requests. Many temporary objects. Handling large JSON. Building temporary lists and arrays.
In such cases, the heap grows until the next GC. When the load stops, a GC runs and the heap may return.
Under load: GC Heap grows
After load stops: GC Heap drops, or returns to a steady value
After repeats: the baseline does not keep rising
In this case, you can conclude “it just hadn’t been GC’d yet” or “there are many temporary allocations.”
That said, if temporary allocations under load are excessive, GC counts and pause times rise and become a performance problem. Even if it is not a leak, it is still a target for performance improvement.
6.4 Post-GC Gen 2 / LOH keeps growing
This is the pattern to watch out for:
Repeat the same operation
↓
Gen 2 grows
↓
LOH grows
↓
Stopping the load does not bring it back
↓
The next measurement shows it even higher
Gen 2 is the generation where long-lived objects reside. The LOH is where large arrays and strings tend to land.
If these keep growing, suspect a leak, an unbounded cache, retained giant buffers, missed event unsubscriptions, static collections, or retention by long-lived services.
At this stage, move on to the next step.
7. How to Think About “Is It Just Waiting on GC?”
To check whether something “just hasn’t been GC’d yet,” look at the state after the GC has had ample opportunity to run.
However, do not casually put GC.Collect() into production code.
GC.Collect() forces a GC. A full blocking GC across all generations in particular creates application pause time. In normal operation, leaving it to the GC is the rule.
Even so, during an investigation you may check “does it survive a forced GC?” in a controlled verification environment.
In a verification console app or reproduction environment, code like this lets you inspect the state after a full GC:
static void ForceFullGcForDiagnosticsOnly()
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
The point is to never use this as a solution. It is for investigation only.
What you want to confirm is this flow:
Before the operation
↓
Repeat the operation N times
↓
Stop the load
↓
Wait long enough, or induce a full GC in the verification environment
↓
Does the post-GC heap return close to the pre-operation value?
If it returns, GC lag or temporary allocation is likely. If it does not return — and the baseline rises every time you repeat the same operation — something is surviving. You then go looking for that “something” in a dump.
8. Lightweight Comparison with dotnet-gcdump
For the initial comparison, dotnet-gcdump is convenient.
dotnet-gcdump captures a GC dump from a running .NET process, useful for viewing per-type statistics of the heap.
dotnet-gcdump collect --process-id <PID> --output before.gcdump
After applying load, capture again.
dotnet-gcdump collect --process-id <PID> --output after.gcdump
You can produce a simple report from the CLI:
dotnet-gcdump report before.gcdump > before-heap.txt
dotnet-gcdump report after.gcdump > after-heap.txt
What you look at is each type’s Count and Size.
For example, if types like the following grew sharply in after, they become investigation targets:
Size (Bytes) Count Type
============ ===== ====
180,000,000 2,000,000 System.String
120,000,000 1,000,000 MyApp.Models.Customer
90,000,000 25,000 System.Byte[]
What matters is not “the big types” but “the types that grew.”
System.String and System.Byte[] rank high in most applications.
Being at the top does not make them the culprit.
The comparison lens:
| before → after | Reading |
|---|---|
| Count roughly unchanged | That type is probably not the main culprit |
| Both Count and Size grow | A candidate |
MyApp.* types grow |
Suspect retention in business logic |
System.Byte[] grows |
Suspect buffers, serialization, images, compression, HTTP, DB |
System.String grows |
Suspect caches, logging, JSON, dictionary keys, duplicate strings |
Task, Timer, CancellationTokenSource grow |
Suspect async work, Timers, missed cancellations and unsubscriptions |
dotnet-gcdump is an easy entry point for comparison, but capturing one induces a Gen 2 GC. In environments with large heaps or strict latency requirements, watch out for pause time and additional memory consumption.
On Windows, you can open .gcdump files in Visual Studio or PerfView and compare them.
On non-Windows environments, the practical path is to view type statistics with the CLI report and move to dotnet-dump for digging into references.
9. Inspect the Heap and References with dotnet-dump
Once “the growing types” are visible, the next question is “why are they not being collected?”
For that, capture a dump with dotnet-dump and analyze it with SOS commands.
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-1.dmp
Wait a while and capture again.
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-2.dmp
Dump capture is a heavy operation. Full / Heap dumps in particular are large and put load on the process and the container. When taking them in production, mind the time of day, disk space, container memory limits, and the possibility of personal or confidential data ending up in the dump.
Analyze the captured dump:
dotnet-dump analyze myapp-2.dmp
First look at overall heap statistics:
> dumpheap -stat
The output is counts and sizes per type:
MT Count TotalSize Class Name
00007f... 120000 3840000 MyApp.Models.Order
00007f... 250000 8000000 System.String
00007f... 10000 40000000 System.Byte[]
Narrow down to a specific type:
> dumpheap -stat -type MyApp.Models.Order
Or filter by a specific MethodTable:
> dumpheap -mt <MT>
Once you have an instance address, look up its references:
> gcroot <OBJECT_ADDRESS>
This is the most important part. With gcroot, you confirm why that object is alive.
Say you see a reference path like this:
static MyApp.CustomerCache._items
-> System.Collections.Concurrent.ConcurrentDictionary<string, Customer>
-> MyApp.Models.Customer
-> System.String
In this case, the reason the GC won’t collect is clear: Customer is referenced from a static cache, so from the GC’s perspective it is still in use.
Only now can you make the next set of judgments:
- Is that cache really needed?
- Does it have a size limit?
- Does it have an expiration?
- Is the design such that keys keep growing?
- Is it keyed on tenant, user, date, or request ID and growing without bound?
The crucial thing in a leak investigation is not to stop at dumpheap -stat.
dumpheap -stat tells you “what there is a lot of”; gcroot tells you “why it remains.” It is the latter that leads to a fix.
10. Quick Reference for Diagnosis
Here are the patterns that come up most in practice:
| Observation | Possibility | What to look at next |
|---|---|---|
| Only Total Allocated grows | Normal allocation, or excessive allocation | Allocation Rate, GC counts, CPU, dotnet-trace |
| Working Set grows but GC Heap is stable | Native memory, JIT, stacks, OS-side retention | Thread count, handle count, native tools, external libraries |
| GC Heap grows only under load, returns after | Waiting on GC, temporary allocations | Gen 2 / LOH after load stops, GC counts |
| Post-GC Gen 2 keeps growing | Retention of long-lived objects | dumpheap -stat, gcroot |
| LOH keeps growing | Large arrays, buffers, fragmentation, giant strings | System.Byte[], System.Char[], LOH, Free regions |
System.String is large |
String caches, JSON, logging, dictionary keys | Find your own types holding the strings |
System.Byte[] is large |
Buffers, serialization, images, compression, networking | Owning types, missed ArrayPool returns, native interop |
Task grows |
Async work that never completes, waiting queues | async coordination, cancellation, channels, queues |
Timer grows |
Missed Timer disposal | Dispose, deregistration, long-lived services |
CancellationTokenSource grows |
Missed CTS disposal, too many linked tokens | Dispose, unlinking, where timeouts are created |
EventHandler or delegates remain |
Missed event unsubscription | Lifetime gap between publisher / subscriber |
| Finalization Queue grows | Missed Dispose, clogged finalizer |
finalizequeue, the finalizer thread |
| Many pinned handles | Pinned buffers, native interop | gchandles, POH, pinning locations |
11. Common Leak Shapes
Seven patterns follow, but you do not have to read them in order. Start from the row that most resembles the symptom in front of you.
| Section | Pattern | Typical symptom | First metric to check |
|---|---|---|---|
| 11.1 | Static collections | Grows in proportion to the number of operations and does not return when the load stops | Gen 2. Whether gcroot surfaces a static field |
| 11.2 | Unbounded caches | Grows in proportion to uptime. Returns after a restart | Gen 2. Cache entry count and how System.String grows |
| 11.3 | Missed event unsubscriptions | Grows every time a screen or scope is opened and closed | Count of the ViewModel or handler type involved. gcroot through a delegate |
| 11.4 | Missed Timer disposal | Objects meant to be short-lived never go away and their callbacks keep firing | Count of System.Threading.Timer or TimerQueueTimer |
| 11.5 | Missed IDisposable releases |
The GC Heap is stable, yet handle counts and process memory grow | Handle count, Finalization Queue, Working Set |
| 11.6 | AsyncLocal and context retention |
DTOs remain after request processing has finished | gcroot through the async state machine |
| 11.7 | Mismatched DI lifetimes | Grows in proportion to the number of requests | gcroot from the singleton type |
Use the “first metric to check” column together with the quick reference in section 10. Section 10 is the table that narrows possibilities down from an observation; this table is the one that goes back from a pattern to the metric you should confirm.
11.1 Static collections
The most straightforward shape.
public static class CustomerStore
{
private static readonly List<Customer> Customers = new();
public static void Add(Customer customer)
{
Customers.Add(customer);
}
}
In this code, every Customer added to Customers stays around as long as the process lives. Even if it was meant as temporary storage, the GC will not collect anything referenced from a static.
The direction of the fix depends on the use case:
- Impose a size limit
- Add an expiration
- Use a caching mechanism such as
MemoryCache - Remove items explicitly
- Drop the static and move to a service with the appropriate lifetime
- If the goal is persistence, move to a DB or external storage
The point is not that “statics are bad” — it is understanding and respecting the property that whatever you put in a static becomes long-lived.
11.2 Unbounded caches
A cache uses memory intentionally, so growth as designed is not a leak. But a cache without a limit or expiration becomes a de facto memory leak.
public sealed class ReportCache
{
private readonly Dictionary<string, Report> _cache = new();
public Report GetOrCreate(string userId, DateTime date)
{
var key = $"{userId}:{date:O}";
if (_cache.TryGetValue(key, out var report))
{
return report;
}
report = BuildReport(userId, date);
_cache[key] = report;
return report;
}
}
In this example, if the combinations of userId and date keep growing, the cache keeps growing too.
Especially dangerous are keys that include values like these:
- Request IDs
- The current time
- GUIDs
- Session IDs
- User input used as a string without normalization
- SQL or search conditions stringified verbatim
For any cache, settle these conditions:
| Condition | Example |
|---|---|
| Maximum count | Up to 10,000 entries |
| Maximum size | Up to 256MB |
| Sliding expiration | 30 minutes after last access |
| Absolute expiration | 6 hours after creation |
| Eviction triggers | Tenant deletion, user deletion, configuration change |
| Monitoring items | Count, estimated size, hit rate, eviction count |
The rule is not “it’s a cache, so it may grow” but “decide how far it may grow.”
11.3 Missed event unsubscriptions
Events leak when a long-lived publisher keeps referencing a short-lived subscriber.
public sealed class OrderViewModel
{
private readonly OrderService _service;
public OrderViewModel(OrderService service)
{
_service = service;
_service.OrderChanged += OnOrderChanged;
}
private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
{
// update view model
}
}
If OrderService is a singleton and an OrderViewModel is created per screen, the OrderService event keeps referencing the OrderViewModel. Closing the screen does not free the ViewModel unless it unsubscribes.
A fix:
public sealed class OrderViewModel : IDisposable
{
private readonly OrderService _service;
public OrderViewModel(OrderService service)
{
_service = service;
_service.OrderChanged += OnOrderChanged;
}
public void Dispose()
{
_service.OrderChanged -= OnOrderChanged;
}
private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
{
// update view model
}
}
In gcroot, this can appear as references via delegates or event handlers.
This pattern shows up frequently in WPF, WinForms, long-lived services, message brokers, and event aggregators.
11.4 Missed Timer disposal
System.Threading.Timer, PeriodicTimer, and Reactive Extensions subscriptions also stick around if not disposed.
public sealed class PollingWorker
{
private readonly Timer _timer;
public PollingWorker()
{
_timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
}
private void Poll()
{
// polling
}
}
If this PollingWorker is meant to be a temporary object, the design must dispose the Timer.
public sealed class PollingWorker : IDisposable
{
private readonly Timer _timer;
public PollingWorker()
{
_timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
}
public void Dispose()
{
_timer.Dispose();
}
private void Poll()
{
// polling
}
}
A Timer holds its callback delegate, and from there a reference chain can lead to the target object.
11.5 Missed IDisposable releases
Missed IDisposable releases do not necessarily show up as managed heap leaks.
They can surface as resource problems with files, sockets, DB connections, native handles, and buffers.
public async Task<string> ReadAsync(string path)
{
var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
In this example, StreamReader closes the stream, so it is often not a major problem — but in code where ownership is ambiguous, leaks happen.
The rule is to make ownership explicit with using / await using:
public async Task<string> ReadAsync(string path)
{
await using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
Missed Dispose shows up as symptoms like these:
- Handle counts increase
- Sockets accumulate
- Files never get closed
- Native memory grows
- The Finalization Queue grows
- Process memory grows while the GC Heap stays stable
In this case, dumpheap alone is not enough.
Also look at OS handles, sockets, and the state of external libraries.
11.6 AsyncLocal and context retention
AsyncLocal<T> is convenient, but if what you store in it is large, it can persist for a long time.
A small value like a log correlation ID rarely causes problems. But putting user info, request bodies, large DTOs, or DB contexts in it leads to unintended retention.
public static class RequestContext
{
public static readonly AsyncLocal<RequestInfo?> Current = new();
}
Because AsyncLocal rides the async flow, it can be harder to spot than a plain static field.
Keep what you store small and well-defined, and consider a design that resets it to null when no longer needed.
11.7 Mismatched DI lifetimes
In DI containers such as ASP.NET Core’s, singleton, scoped, and transient have different lifetimes.
If a long-lived singleton holds per-request data, objects can remain after the request ends.
public sealed class AuditBuffer
{
private readonly List<RequestAudit> _items = new();
public void Add(RequestAudit item)
{
_items.Add(item);
}
}
If this is a singleton, _items lives as long as the application.
If buffering is intentional, the design needs a limit, flushing, removal, and backpressure. If it is merely “we might look at it later,” it should go to logs or external storage instead.
12. The LOH Is Especially Easy to Misread
LOH stands for Large Object Heap. In .NET, large objects are placed on a separate heap from ordinary small objects. The classic example is a large array.
var buffer = new byte[1024 * 1024 * 10]; // 10MB
The three common LOH problems:
- Creating large objects frequently
- Holding large objects for a long time
- Fragmentation from creating and discarding large objects
A growing LOH does not immediately mean a leak. With a design that reuses large buffers, it may grow to a certain size and then stabilize — and even when the GC collects, the Working Set does not necessarily drop right away.
However, you should be suspicious of these states:
System.Byte[]grows with each operationSystem.Char[]or giantStringinstances grow- Memory does not return after image, PDF, Excel, ZIP, cryptography, or compression work
- Arrays from
ArrayPool<T>.Rentare not being returned - Entire large responses are being loaded into memory
- Heavy use of
MemoryStream.ToArray()
When using ArrayPool<T>, always return what you rent:
var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);
try
{
// use buffer
}
finally
{
pool.Return(buffer);
}
That said, returning to the pool does not necessarily reduce the process’s memory right away. Pools may retain memory for reuse.
Here too, what you should look at is: does it keep growing, is there a limit, and is the memory being reused?
13. How to Read gcroot
gcroot shows where an object is referenced from.
Typical roots, summarized:
| Root | Meaning |
|---|---|
| static field | Referenced from a type’s static field |
| local variable / stack | Referenced from a running thread’s stack |
| GC handle | Referenced via GCHandle, pinning, delegates, interop |
| finalization queue | Held pending finalization |
| thread / async state machine | Held by running or awaiting async work |
The key thing to look for is the lifetime gap:
Long-lived object
-> object that should be short-lived
When this shape appears, you have a leak candidate.
For example, this is suspicious:
SingletonService
-> List<RequestContext>
-> RequestContext
-> LargeDto
SingletonService lives for the whole application.
If per-request RequestContext objects are piling up inside it, the design needs rethinking.
On the other hand, a root like this can be perfectly normal depending on timing:
Thread stack
-> Controller action local variable
-> RequestDto
If a request is in flight, of course local variables are still alive.
That is why dump timing matters.
Take dumps not only under load, but also after load stops, after queues drain, and after a period of idleness — that makes the judgment far easier.
14. “It Dropped After a Forced GC” Does Not Mean “Solved”
During an investigation, you call GC.Collect() and memory drops. It is dangerous to conclude “then we should just call GC.Collect() periodically.”
A forced GC does not remove the root cause. It merely collected, on the spot, objects that had not yet been collected.
If a high allocation rate is the problem, forced GC increases pause time and worsens performance. If it is a real leak, objects with surviving references are not collected by a forced GC either.
What to look at during the investigation is this distinction:
| After a forced GC | Judgment |
|---|---|
| Drops sharply, then the baseline stays stable | GC lag or temporary allocations were the main cause |
| Drops a little, but the floor rises with each repetition | Some objects are surviving. Leak candidate |
| Barely drops | Still referenced, or the main cause is outside the GC heap |
| GC Heap drops but Working Set does not | Possible OS / GC segment / native-side retention |
Before scheduling GC.Collect() in production, always identify what is actually growing.
15. A Field Procedure for the Investigation
From here, let’s lay this out as the procedure you actually follow.
15.1 Fix the reproduction scenario
First, pin down the investigation conditions.
Target: /api/report/export
Operation: run 100 times with identical conditions
Sample interval: 5 seconds
Window: 5 min warm-up + 10 min load + 5 min idle
Environment: staging / Release build / production-equivalent settings
You cannot draw conclusions from a memory investigation while doing something different each time. Pin down “what we did when it grew.”
15.2 Take a baseline
Use the post-warm-up state as the baseline, not right after startup.
The reason is that one-time growth happens right after startup:
- JIT
- DI container construction
- Configuration loading
- First DB connection
- First TLS / HTTP connection
- JSON serializer metadata generation
- Razor / template initialization
- Logger and metrics initialization
The order:
1. Start the app
2. Hit health checks and representative APIs a few times
3. Wait 1-5 minutes
4. Capture counters and a dump as the baseline
15.3 Collect counters under load
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output report-export-counters.csv \
--counters System.Runtime
Run the reproduction in parallel. What you want is the shape of the graph.
Near-normal shape:
grows under load
oscillates with GCs
returns after load stops
the baseline does not keep rising
Suspicious shape:
grows in proportion to operation count
the Gen 2 / LOH floor rises
does not return after load stops
the floor rises further with the next load
15.4 Take two dumps
Take them before and after the load.
dotnet-dump collect --process-id <PID> --type Heap --output before.dmp
# apply load
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp
If you have capacity, take one after the load stops too.
# after load stops, queues are empty, and you have waited a while
dotnet-dump collect --process-id <PID> --type Heap --output idle-after.dmp
When comparing, idle-after matters as much as before and after.
Even if memory grew under load, returning after idle may mean it is not a leak.
15.5 Look at the types that grew
dotnet-dump analyze after.dmp
> dumpheap -stat
Inspect the before side the same way.
Doing this by hand is fine — start by comparing the top types.
Things to look for:
- Are types in your own namespaces growing?
- Is one of your own types hiding behind
System.String? - Who owns the
System.Byte[]instances? - Are
List<T>orDictionary<TKey,TValue>growing? - Are
Taskinstances or async state machines growing? - Are
TimerorCancellationTokenSourceinstances growing?
15.6 Look at the references
Pick up candidate object addresses and run gcroot.
> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>
From the gcroot output, find the retaining parent.
MyApp.Services.ReportCache
-> Dictionary<string, ReportResult>
-> ReportResult
At this point, the code review targets become visible:
- Is
ReportCachea singleton? - Does it have a limit?
- Do entries get removed?
- Do the keys keep growing?
- Is
ReportResulttoo large? - Should this go to a DB or files instead of a cache?
16. When to Use dotnet-trace
dotnet-dump is a point-in-time snapshot, suited for seeing “what ended up remaining.” When you instead want to see “when and where the heavy allocations happen,” use dotnet-trace.
For example, trace with GC-related events included:
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:01:00 \
--clrevents gc+gchandle \
--clreventlevel informational \
--output gc-trace.nettrace
If you want allocation sampling too, the event volume increases, so start with short runs in a verification environment:
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:00:30 \
--clrevents gc+gcsampledobjectallocationhigh \
--clreventlevel informational \
--output allocation-trace.nettrace
Traces help from a different angle than dumps:
| What you want to see | The right tool |
|---|---|
| What remains | dump / gcdump |
| Who is referencing it | dump + gcroot |
| When heavy allocations happened | trace |
| When GCs occurred | counters / trace |
| Whether pause time is the problem | counters / trace |
In a leak investigation, it is efficient to first use dumps to see “what is remaining,” and then, if needed, traces to see “where it is being created.”
17. Expose Verification Metrics in Code
Serious diagnostics should be done with external tools, but having simple diagnostic logging in the application helps.
For example, exposing GC information via an admin endpoint or periodic log:
public static class GcDiagnostics
{
public static object Snapshot()
{
var info = GC.GetGCMemoryInfo();
return new
{
TotalMemory = GC.GetTotalMemory(forceFullCollection: false),
HeapSizeBytes = info.HeapSizeBytes,
FragmentedBytes = info.FragmentedBytes,
MemoryLoadBytes = info.MemoryLoadBytes,
HighMemoryLoadThresholdBytes = info.HighMemoryLoadThresholdBytes,
Gen0Collections = GC.CollectionCount(0),
Gen1Collections = GC.CollectionCount(1),
Gen2Collections = GC.CollectionCount(2)
};
}
}
This information alone cannot determine a leak. But during an incident, it makes the following judgments easier:
- Is Gen 2 spiking?
- Is HeapSize growing?
- Is FragmentedBytes growing?
- Is the gap between TotalMemory and process memory large?
- Did the trend change after a deployment?
If you put this in application logs, watch the volume. Heavy diagnostics at high frequency become a load in themselves.
18. The Bar for Declaring “Memory Leak”
At the end of the investigation, you should be able to explain it in the form below. As a template for the written report, here are the fields to fill in.
| Field | What to write |
|---|---|
| Symptom | Who is affected. Which operation makes it grow, and by how much |
| Observation conditions | Environment, build configuration, data volume, number of runs, warm-up time, sampling interval, tools used and their versions |
| Observation | How the counter values moved. Write Working Set and GC Heap separately |
| Comparison | Which types grew, and by how many instances, between the before and after dumps |
| References | The retaining path that gcroot revealed |
| Cause | Which design decision in the code produces that retention |
| Fix | What you will change. How you will measure the effect |
| Open items | If any. What this round of observation could not explain, and what to look at next |
Not dropping the observation conditions matters most. Without them, you cannot compare a re-measurement after the fix, and the verification in section 20 loses its meaning.
Filled in against this template, a report looks like this:
Symptom:
After running /api/report/export 100 times, the GC Heap stays 300MB higher even after load stops.
Observation conditions:
staging environment / Release build / production-equivalent settings and data volume.
After a 5 minute warm-up, 100 runs under identical conditions, then 5 minutes idle.
dotnet-counters collected at 5 second intervals.
Observation:
In dotnet-counters, Gen 2 heap size grew in proportion to the operation count.
Not just Working Set — the GC Heap was growing too.
Comparison:
Comparing before.dmp and after.dmp, MyApp.Models.ReportResult grew by 12,000 instances.
References:
gcroot showed references from MyApp.Services.ReportCache._items.
Cause:
ReportCache was a singleton keyed on user ID + current time, with no removal, expiration, or limit.
Fix:
Replaced with MemoryCache, configured a size limit and expiration.
Added the cache entry count as a metric.
If you can explain this far, the report is no longer just “memory is growing” — it connects reproduction conditions, observations, the growing type, the references, the cause, and the fix.
19. Caveats During the Investigation
19.1 Investigate with Release builds
Debug builds can look different from production due to optimizations, local variable lifetimes, and debug information.
For production-equivalent investigation, verify with a Release build, near-production settings, and similar data volumes.
19.2 Don’t judge from right after startup
Right after startup, memory grows due to all sorts of initialization.
Take a post-warm-up baseline, and look at growth from there.
19.3 Don’t convict based on a single dump
The types at the top of the heap are not necessarily the culprits.
System.String and System.Byte[] look large in most applications.
What matters is whether they grew over time, and who holds them.
19.4 Dumps contain sensitive data
Memory dumps may contain requests, credentials, connection strings, personal information, and business data.
Establish rules for storage location, removal from premises, sharing, and deletion.
19.5 In containers, dump capture is itself a risk
Under tight container memory limits, the extra memory and page-ins caused by dump capture can get the container OOM-killed.
Before capturing in a production container, try it in staging and check limits, disk space, permissions, and the PID namespace.
19.6 There are leaks outside the GC heap too
Investigating .NET does not mean everything shows up on the GC heap.
In problems like the following, process memory can grow while the GC Heap stays stable:
- Native libraries
- P/Invoke
- COM
- Image processing
- Compression libraries
- Cryptography
- DB drivers
- Sockets
Marshal.AllocHGlobalNativeMemory.Alloc- Excessive threads
In this case, dotnet-dump’s dumpheap is not enough.
You need OS-side diagnostics, external library metrics, handles, threads, and native memory.
19.7 Settle the ground rules with stakeholders before taking a dump in production
Capturing a dump in production is a coordination task before it is a technical operation. Skip that and simply say “let us capture one, it is for the investigation,” and you will usually be told no.
Start by laying out the impact you need to explain.
- Dump capture is a heavy operation for the target process, and during capture the process can appear to stop responding. How long it stops depends on heap size, disk speed, and the environment, so run the same procedure once in staging and measure it.
- If the process stops responding for long enough, it can trigger health check timeouts, removal from the load balancer, or a cluster failover.
FullandHeapdumps grow with the process’s memory usage. Check the free disk space at the destination first.- In containers, the page-ins that accompany dump capture can push the process past the memory limit and get the container killed (19.5).
- Dumps may contain personal information and credentials (19.4).
On top of that, settle the following before capturing.
| What to settle | Concrete example |
|---|---|
| Who approves | The service owner and the IT department. Get both to agree in advance |
| When to capture | Outside business hours, or another window where a temporary slowdown is acceptable |
| Where to write it | A local disk with plenty of free space. Do not write directly to a shared folder |
| Who can access it | Restrict access rights on the storage location to the investigators |
| When to delete it | Decide and record the deletion deadline after the investigation, in advance |
| What to do if it fails | The restart procedure if the process does not recover, and who makes that call |
| Advance rehearsal | Run the same procedure once in staging and note the elapsed time and file size |
When you send the request, handing over a one-page summary of this is more reliable than saying it verbally. Here is a sample form.
Purpose: Identify why memory does not return after running /api/report/export
Method: Capture dotnet-dump collect --type Heap twice, before and after load
Impact: The target process responds more slowly during capture.
Measured values from staging attached separately
Time window: Outside business hours. A time when the owner and IT can both attend
Destination: Local disk on the target server. Free space checked in advance
Data included: May contain request data in memory, connection strings, and similar
Handling: Access rights on the storage location limited to the investigators. No removal from premises
Deletion: Delete after the investigation and within one month at the latest, and keep a deletion record
Rollback: If the process does not recover after capture, restart it
When “what for,” “how long it stops,” and “where it is stored and when it is deleted” are all present, the people making the call can answer yes or no easily. When those are vague, the investigation itself stalls.
20. Verifying After the Fix
Once you fix the suspected leak, re-measure with the same procedure.
Before the fix:
After 100 runs, Gen 2 grew +300MB
ReportResult grew +12,000 instances
After the fix:
After 100 runs, Gen 2 stays stable within +20MB
ReportResult returns to baseline after load stops
Cache entry count stable at the 1,000-entry limit
When verifying a fix, always compare under identical conditions:
- Same data volume
- Same number of runs
- Same load duration
- Same warm-up
- Same sampling interval
- Same tools
A memory investigation is not convincing if the before/after comparison is weak.
21. Conclusion
When memory is growing in .NET, don’t immediately declare a leak — triage in this order:
- Don’t judge from Working Set / RSS alone
- Look at GC Heap, Gen 2, LOH, and GC counts with
dotnet-counters - Compare under load, after load stops, and over time
- Look at the growing types with
dotnet-gcdumpordotnet-dump - Look at the references with
gcroot - Check statics, caches, events, Timers, DI lifetimes, async contexts
- If the GC Heap is stable, also suspect native memory and OS-side issues
The difference between “just not GC’d yet” and “leaking” is, in the end, decided by references.
If an object that is no longer needed has no references, it gets collected when the GC runs. If something that should be unnecessary is still being referenced, the GC cannot collect it.
In other words, the goal of the investigation is:
What is growing?
Does it survive every GC?
Who is referencing it?
Is that reference required by the design?
Once you know this much, you can stop being whipsawed by memory graphs and translate the problem into concrete fixes in the code.
References
- The complete sample code for this article (library, demo, unit tests) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/dotnet-gc-or-memory-leak
- .NET: Fundamentals of garbage collection
- .NET: Debug a memory leak
- .NET CLI: dotnet-counters diagnostic tool
- .NET CLI: dotnet-dump diagnostic tool
- .NET CLI: dotnet-gcdump diagnostic tool
- .NET CLI: dotnet-trace diagnostic tool
- .NET: Induced collections
- .NET: Large object heap
- .NET: Workstation and server garbage collection
- .NET Framework: Performance counters
- .NET Framework: SOS.dll (SOS Debugging Extension)
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
What Is a PDB (Program Database)? — Understanding Debug Information, Symbols, and Source Link
What a PDB (Program Database) file is, what it does and does not contain, and how it relates to Debug / Release, Portable PDB, Source Lin...
Handling Windows Impersonation Tokens Correctly — Borrowing Privileges per Thread and Reverting Safely
A practical guide to Windows impersonation tokens — access tokens, primary tokens, thread tokens, impersonation levels, RevertToSelf, and...
The Misconception That TCP Lets You Receive in the Same Units You Send — Designing Reception Around a Byte Stream
Assuming TCP delivers data in the same units as Send or Write leads to fragmentation, coalescing, garbled text, and broken protocols. Thi...
End of Servicing for Windows Printer Drivers — How Business Apps Should Prepare Their Report and Label Printing
Microsoft is phasing out v3/v4 printer drivers. What Windows protected print mode removes, and how to inventory and prepare report and la...
DLL and COM Interface Backward Compatibility — A Decision Table for Which Changes Break Callers
Which changes to a DLL or COM component actually break their callers? We lay out the three layers of compatibility — binary, source, and ...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Bug Investigation & Root Cause Analysis
We investigate difficult production issues such as intermittent failures, long-run crashes, leaks, and communication stoppages.
Frequently Asked Questions
Common questions about the topic of this article.
- Is growing memory usage in a .NET application a memory leak?
- A process using more memory and a process leaking memory are not the same thing. In .NET the GC runs based on allocation patterns and heap thresholds, so objects that are no longer needed may simply not have been collected yet, and even after a collection the OS may not reclaim the memory right away. The three things to look at are whether the memory that survives GC is growing, which types are growing, and who is referencing those objects.
- Which tools should I use to investigate a memory leak in .NET?
- Start with dotnet-counters to watch trends in Working Set, GC Heap, Gen 2 / LOH, and GC counts. Next, capture GC dumps with dotnet-gcdump before and after load and compare Count and Size per type to identify which types grew. Finally, take a heap dump with dotnet-dump and use dumpheap -stat and gcroot to trace the references that explain why the objects are not collected. What matters is comparing over time under identical conditions, not a single reading.
- What are the common memory leak patterns in .NET?
- Typical examples are static collections that only ever get added to, unbounded caches with no size limit or expiration, missed event unsubscriptions from a long-lived publisher, undisposed Timers, missed IDisposable releases, and DI lifetime mismatches where a singleton holds per-request data. A .NET leak is easier to reason about as unintended retention, something still referenced even though it is no longer needed, rather than as forgetting to free memory.
- Does running GC.Collect() periodically solve memory problems?
- No. A forced GC only collects, on the spot, objects that had not been collected yet; it does not remove the root cause. If a high allocation rate is the problem, it adds pause time and makes performance worse, and if it is a real leak, objects that are still referenced are not collected by a forced GC either. Checking whether objects survive a forced GC is a legitimate investigation step in a controlled verification environment, but before putting it into production as a solution you must first identify what is actually growing.