A .NET application that starts with low memory usage can gradually consume more memory as it handles requests, processes messages, or runs background jobs. Restarting the process may temporarily restore normal behavior, but it does not explain why memory keeps growing.
The problem could be a genuine memory leak, an unexpectedly large cache, retained objects, excessive allocation, or a garbage collector that has not yet reclaimed memory. These issues can look similar in production metrics, but they require different fixes.
A managed heap dump helps developers investigate what the application is retaining in memory. It captures information about managed objects at a particular point in time, allowing engineers to examine object types, reference relationships, and memory consumption.
This article explains how to capture a dump from a running .NET process, analyze it with diagnostic tools, identify common retention problems, and verify the fix without relying on guesswork.
What Is a Managed Heap Dump?
A managed heap dump is a snapshot of managed memory associated with a .NET process. Depending on how it is collected, the dump can contain information about managed objects, their types, references, and the state of the runtime.
The garbage collector automatically reclaims managed objects that are no longer reachable from relevant roots. A memory leak occurs when objects remain reachable even though the application no longer needs them.
For example, an application might store every processed request in a static collection. Even after a request finishes, the collection continues to reference its objects. Because those objects remain reachable, the garbage collector cannot reclaim them.
A heap dump can help reveal this pattern by showing which object types consume memory and how objects are connected to long-lived references.
However, a single dump does not always prove that a leak exists. A large heap may be legitimate, especially when the application uses caches or processes large workloads. Comparing multiple dumps and observing memory behavior over time provides stronger evidence.
How to Recognize a Possible Memory Leak
Before capturing a dump, establish whether the application is experiencing sustained memory growth.
Useful signals include:
Managed heap size increases over repeated workload cycles.
The garbage collector runs frequently, but memory remains high.
The process approaches its memory limit even when request traffic is stable.
Large collections continue growing without a clear retention policy.
Memory usage falls after restarting the process but rises again over time.
Start with operating-system or container metrics to understand the overall process footprint. Then inspect .NET runtime metrics to determine whether managed memory is responsible.
For example, the following command starts a live view of .NET runtime counters:
dotnet-counters monitor --process-id 1234
Replace 1234 with the actual process ID.
Look at counters such as GC heap size, allocation rate, garbage collection counts, and time spent in garbage collection. Available counter names vary by runtime version and tool release.
If the total process memory grows while managed heap size remains relatively stable, investigate native allocations, unmanaged libraries, memory-mapped files, and other process-level resources. A managed heap dump alone will not explain every kind of memory growth.
Step 1: Find the Target .NET Process
The diagnostic tools must attach to the correct process.
List available .NET processes:
dotnet-counters ps
Alternatively, use the operating system's process-management tools to identify the process ID.
Confirm the process belongs to the expected application, especially on a host running multiple services.
Before collecting diagnostic data from production, consider the operational impact. Dump generation can consume additional CPU, memory, and disk space. Full dumps can be large, and diagnostic access may expose sensitive application data.
Choose an appropriate collection window and ensure that the destination has sufficient storage.
Step 2: Capture a Heap Dump
The dotnet-dump diagnostic tool can collect and analyze dumps from supported .NET processes.
Install it as a global tool if it is not already available:
dotnet tool install --global dotnet-dump
If the tool is already installed, update it when appropriate:
dotnet tool update --global dotnet-dump
To capture a dump from a running process:
dotnet-dump collect --process-id 1234 --output memory-leak.dmp
Replace the process ID and output filename with the values appropriate for your environment.
The tool collects a dump that can be inspected for managed-memory information. Depending on the runtime, collection mode, and tool version, the available dump content can vary.
For production use, review the diagnostic tool's options and documentation before selecting a dump type. Full dumps provide more information but may require substantial storage and can contain sensitive data.
Capture Dumps at Different Times
A useful investigation often needs more than one snapshot.
Capture a baseline dump after the application has warmed up. Then run a representative workload and capture another dump after memory usage has increased.
For example:
dotnet-dump collect --process-id 1234 --output baseline.dmp
After the application processes additional requests:
dotnet-dump collect --process-id 1234 --output after-workload.dmp
Use comparable workload conditions where possible. If the first dump is captured immediately after startup and the second after a large import, the difference may reflect normal application behavior rather than a leak.
Record the time, traffic level, GC heap size, process memory, and workload characteristics for each dump. That context makes the comparison more meaningful.
Step 3: Open the Dump with dotnet-dump
Start the interactive analysis shell:
dotnet-dump analyze memory-leak.dmp
The analyzer supports commands for inspecting managed objects, heap composition, and object references.
Two particularly useful commands are dumpheap and gcroot.
Inspect Heap Composition
Run:
dumpheap -stat
This displays a summary of managed object types, object counts, and total memory attributed to those types.
Look for types that account for a substantial portion of the heap or appear to grow unexpectedly between snapshots.
For example, a heap summary might reveal a large number of strings, byte arrays, dictionaries, or application-specific objects.
The interpretation depends on the application. A service that processes documents may legitimately retain byte arrays while processing a batch. A web service that accumulates request objects indefinitely may indicate an unintended retention problem.
You can also inspect objects of a particular type:
dumpheap -type System.Byte[]
This narrows the output to byte arrays, which can be useful when large buffers dominate memory usage.
The exact output depends on the dump and runtime. Avoid treating object count alone as a measure of severity; total size and reference relationships are equally important.
Inspect a Specific Object
If the heap summary identifies a suspicious object, inspect its address:
dumpobj 00007ff012345678
Replace the sample address with an address returned by the analyzer.
The command displays information about the object, including its type and fields. Addresses are specific to the dump, so never reuse an address from one snapshot when analyzing another.
For collections, inspect the object structure and determine whether it holds references to many other objects. A small dictionary can retain a large amount of memory if its entries reference large object graphs.
Step 4: Find What Keeps an Object Alive
Finding a large object is only part of the investigation. The next question is why the garbage collector cannot reclaim it.
Use gcroot with the address of a suspicious object:
gcroot 00007ff012345678
The command attempts to identify reference paths from garbage-collection roots to that object.
A root may be associated with a static field, a stack reference, a handle, or another runtime-managed reference. If a long-lived static collection references the object, the output can help explain why it remains alive.
Consider this example:
public sealed class RequestRecord
{
public required string RequestId { get; init; }
public required byte[] Payload { get; init; }
}
public static class RequestHistory
{
public static readonly List<RequestRecord> Records = new();
public static void Add(RequestRecord record)
{
Records.Add(record);
}
}
Every record added to RequestHistory.Records remains referenced by a static collection. Unless the collection removes entries, the records and their payload arrays can remain alive for the lifetime of the process.
A heap dump may reveal the growing list and its retained object graph.
The important finding is not simply that RequestRecord objects exist. It is that a long-lived collection continues holding references to objects the application may no longer need.
Step 5: Fix the Retention Problem
The correct fix depends on why the objects remain reachable.
For an unbounded request-history collection, possible solutions include:
Store only the fields required for diagnostics.
Apply a maximum number of retained entries.
Use a bounded queue or ring buffer.
Move durable history to an external storage system.
Remove entries when their retention period expires.
For example, a bounded queue can limit the number of records held in memory:
using System.Collections.Concurrent;
public sealed class RequestHistory
{
private readonly ConcurrentQueue<string> _records = new();
private readonly int _capacity;
public RequestHistory(int capacity = 1000)
{
if (capacity <= 0)
{
throw new ArgumentOutOfRangeException(nameof(capacity));
}
_capacity = capacity;
}
public void Add(string requestId)
{
_records.Enqueue(requestId);
while (_records.Count > _capacity)
{
_records.TryDequeue(out _);
}
}
}
This example demonstrates a bounded retention policy, but it is not a complete high-throughput bounded-buffer implementation. ConcurrentQueue.Count and the enqueue/dequeue sequence do not enforce a strict capacity atomically under concurrent writers. Production implementations that require a hard memory limit should use an appropriately synchronized bounded structure.
The broader principle is to define an explicit retention policy rather than assuming the garbage collector will remove objects that remain referenced.
Step 6: Compare Before and After Dumps
After implementing a fix, repeat the same workload and collect another dump.
Compare the object counts and total sizes for suspicious types. Also examine whether the reference paths that previously retained the objects still exist.
A successful fix should produce evidence such as:
The number of retained objects stops growing indefinitely.
The managed heap reaches a more stable level after workload cycles.
The problematic root-reference path disappears or retains only the intended number of objects.
Memory usage remains within the application's operational limits.
Memory does not always return immediately to its previous level. The runtime may retain committed memory for future allocations, and caches may legitimately preserve objects. Evaluate trends across comparable workload cycles rather than expecting the process memory figure to drop to a precise value.
Other Tools for Heap-Dump Analysis
dotnet-dump is useful for command-line investigations, but other tools can help depending on the problem.
Visual Studio Diagnostic Tools provide a graphical workflow for supported environments, including memory analysis and object-retention inspection.
PerfView can help investigate managed allocations and garbage-collection behavior, particularly on Windows.
JetBrains dotMemory provides graphical heap analysis and comparison features that can make object retention easier to investigate.
Choose tools based on the runtime, operating system, dump format, and available diagnostic data. Not every tool supports every dump type or every deployment environment.
Production Considerations
A dump may contain credentials, tokens, personally identifiable information, request payloads, and other sensitive data. Treat it as a sensitive production artifact.
Restrict access, encrypt it during storage and transfer, and delete it according to your incident-response or diagnostic-data retention policy.
Also verify that the process permits diagnostic access. Container security settings, user permissions, runtime configuration, and deployment restrictions can prevent dump collection.
Finally, avoid repeatedly capturing large dumps from an already memory-constrained process. Collection itself can require additional resources and may worsen an incident. When the application is close to its memory limit, evaluate the risk before triggering a full dump.
Summary
A managed heap dump helps explain why a .NET application retains memory. By examining heap statistics, inspecting suspicious objects, and tracing references with gcroot, developers can move from observing memory growth to identifying the code responsible for it.
The most reliable workflow is to establish a baseline, capture a dump after representative workload, identify growing object types, trace their references, implement a targeted fix, and repeat the same experiment.
The key distinction is between allocated memory and retained memory. The garbage collector can reclaim objects that are no longer reachable, but it cannot determine that a reachable object is no longer useful to the application. Finding and removing those unintended references is the central task when investigating a managed memory leak.
Jasen FiciPosted Oct 21, 2026, 7:22 AM
Nice write-up. It's in today's DotNetNews: https://dotnetnews.beehiiv.com/p/new-post-1407