Confirming a leak, managed versus native growth, dotnet-counters, dotnet-gcdump comparisons, dotnet-dump and gcroot, common leak sources and verifying the fix.
First confirm a leak: memory keeps rising across GCs under steady load, rather than a cache warming up. Then decide managed or native: if the GC heap size (dotnet-counters) grows with the working set, it is managed; if the working set grows while the GC heap is flat, look at native memory. For managed leaks, take two dotnet-gcdump snapshots some time apart and compare which types grow, then use dotnet-dump with dumpheap -stat and gcroot to find what keeps them alive. Typical roots: static collections and caches, event subscriptions, timers, captured closures, DI root-scoped transients, and unbounded queues. Fix, then verify with the same metrics under load.
"I treat a leak investigation as a fixed process. One: confirm it really is a leak, meaning memory keeps rising across full GCs under steady load, not a cache warming up or a one-off spike. Two: decide whether it's managed or native by comparing GC heap size with working set. Three: for managed leaks, take two
dotnet-gcdumpsnapshots some time apart and see which types grow. Four: take adotnet-dump, find instances of the growing type and rungcrootto see the reference chain to a root. Five: fix the last link I own, usually an unbounded cache, an event subscription, a timer, a DI scoping mistake or an unbounded queue, and verify with the same metrics under a soak test. In Kubernetes I collect withdotnet-monitoror a debug container, and I capture data before the OOM killer strikes, because a killed pod leaves nothing to analyse."
| Check | Healthy | Leak |
|---|---|---|
| Working set / GC heap over hours under steady load | Rises, then plateaus | Keeps rising |
| Gen 2 size after full GCs | Returns to a stable baseline | Baseline climbs |
| Correlation | With traffic/warm-up | With time or with a specific operation count |
dotnet-counters monitor -p <pid> --counters System.Runtime
Watch GC heap size, Gen 2 size, LOH size, working set, GC committed bytes, allocation rate, thread count. Note what changed recently: deploys, config, traffic shape.
| Observation | Likely area | Next tool |
|---|---|---|
| GC heap and Gen 2 grow with working set | Managed objects retained | gcdumps + gcroot |
| Working set grows, GC heap flat | Native: native libraries, undisposed native resources, memory-mapped files | dotnet-dump eeheap -gc, maddress, pmap/VMMap |
| Thread count grows | Leaked threads (each has a stack) | clrthreads, thread stacks |
| Handle count grows (Windows) | Undisposed OS handles | Handle tools, SafeHandle owners |
| GC heap grows but objects die in Gen 2 | Not a leak: mid-life objects | GC traces (see the Gen 1 question) |
dotnet-gcdump collect -p <pid> -o before.gcdump (small file; induces a Gen 2 GC).after.gcdump.dotnet-dump collect -p <pid> for a full dump (pauses the process; size β process memory).dotnet-dump analyze <file>:
dumpheap -stat -type <Type> β confirm counts and the MethodTable.dumpheap -mt <MT> β list instances; pick a few.gcroot <address> β the reference chain from a root.| Leak source | Example | Fix |
|---|---|---|
| Unbounded caches | Static ConcurrentDictionary keyed by request data |
MemoryCache with SizeLimit, expiry |
| Event handlers | Scoped objects subscribing to singleton events | Unsubscribe in Dispose |
| Timers | Timer capturing this, never disposed |
Dispose timers; avoid capturing large graphs |
| DI scoping | Transient disposables resolved from the root provider | Scope per unit of work |
| Closures | Long-lived delegates capturing large objects | Capture only what's needed |
| Unbounded queues/channels | Producer faster than consumer | Bounded Channel<T> with backpressure |
HttpClient per request |
Handlers, sockets (managed + native growth) | IHttpClientFactory |
Collectible AssemblyLoadContext |
Plugin never unloads because something references it | Remove references; verify with WeakReference |
| Blocked finaliser thread | Finalisable objects pile up | finalizequeue, fix blocking finaliser |
Logging scopes / AsyncLocal |
Large objects captured in ambient context | Keep ambient data small |
GC.Collect).Our [notification service] grew by [about 200 MB per day] and was restarted nightly to hide it. dotnet-counters showed GC heap and Gen 2 rising with working set and no plateau, so it was managed. Two gcdumps an hour apart showed NotificationContext and its graph growing by [tens of thousands] of instances. In a full dump, gcroot on one instance gave: static field β ConnectionMonitor (singleton) β StatusChanged multicast delegate β NotificationContext. Each scoped context subscribed to the singleton's event and never unsubscribed. I added Dispose that unsubscribes and made sure contexts were created and disposed by DI scopes. Memory went flat in a 48-hour soak test, we removed the nightly restart, and we added a GC-heap-growth alert.
The interviewer checks a structured, tool-based approach to one of the most common production problems.
Green flags: confirm trend, managed versus native split, two-snapshot comparison, gcroot, list of common roots, Kubernetes practicalities, verification.
Red flags: restarts as the solution; guesses without tools; uses GC.Collect to "fix" leaks.
| Tool | Captures | Cost / impact | Best for |
|---|---|---|---|
dotnet-counters |
Live runtime metrics | Negligible | Confirming the trend |
dotnet-gcdump |
Managed object graph (types, counts, references) | Induces a full GC; small file | Comparing snapshots |
dotnet-dump |
Full process memory | Pauses process during write; file β memory size | gcroot, native, threads, finaliser |
dotnet-trace (gc-verbose) |
GC events, allocation sampling | Lowβmoderate | Who allocates what, GC behaviour |
dotnet-monitor |
All of the above via HTTP API / triggers | Sidecar | Production containers, automatic collection |
| PerfView / Visual Studio | Analysis UI | Offline | Diffing gcdumps, reading traces |
| gcdump | Full dump | |
|---|---|---|
| Size | MBs (graph only) | GBs (all memory) |
| Contains field values | No | Yes |
| Native memory | No | Yes |
gcroot exact paths |
Approximate paths in viewer | Exact with SOS |
| Safe to take repeatedly | Mostly (each induces a full GC) | Expensive; pauses process |
Use gcdumps to find what grows; a full dump to find why it's alive and inspect values.
gcroot chain> gcroot 00007f8a1c2d4e80
HandleTable:
00007f8a0bf513f8 (strong handle)
-> 00007f8a1a001018 System.Object[] (static field storage)
-> 00007f8a1a2c3b40 MyApp.ConnectionMonitor
-> 00007f8a1c9e0a10 System.EventHandler<Boolean> (multicast delegate)
-> 00007f8a1c9a8f20 System.Object[] (invocation list)
-> 00007f8a1c2d4e60 System.EventHandler<Boolean>
-> 00007f8a1c2d4e80 MyApp.NotificationContext <- leaked object
Root types you'll see: strong handles (statics, GCHandle), stack (a thread holding it right now), pinned handles, finaliser queue, async state machines referenced from timers/tasks. A stack root on a thread that runs forever (background loop) is a leak too.
| Command | Use |
|---|---|
dumpheap -stat |
Types by count and size |
dumpheap -stat -min 85000 |
Large objects |
dumpheap -mt <MT> |
Instances of a type |
gcroot <addr> |
Path to a root |
dumpobj <addr> / do |
Field values |
eeheap -gc |
GC heap size per region/segment |
maddress -summary |
Native vs managed memory breakdown (newer SOS) |
finalizequeue |
Finalisation backlog |
clrthreads, clrstack |
Threads and stacks |
gchandles -stat |
Handle counts by type |
eeheap -gc (managed) with total process memory (maddress -summary or OS tools).pmap -x <pid>, /proc/<pid>/smaps; Windows: VMMap, RAMMap.SafeHandle owner is disposed; check thread count.MALLOC_ARENA_MAX.kubectl debug -it <pod> --image=<image-with-dotnet-tools> --target=<container> to share the process namespace, or run dotnet-monitor as a sidecar sharing the diagnostic socket (/tmp).dotnet-monitor collection rules on a memory threshold (e.g. GC heap > 80% of limit) to capture data before the kill.DOTNET_DbgEnableMiniDump=1, DOTNET_DbgMiniDumpType, DOTNET_DbgMiniDumpName.| Version | Change |
|---|---|
| .NET Core 3.0 | dotnet-counters, dotnet-trace, dotnet-dump |
| .NET Core 3.1 | dotnet-gcdump |
| .NET 5+ | dotnet-monitor (stable from .NET 6 era) with triggers |
| .NET 8 | System.Runtime metrics via System.Diagnostics.Metrics |
| .NET 9 / 10 | Runtime metrics with OpenTelemetry-friendly names; same investigation workflow |
dumpheap -stat -type NotificationContext, dumpheap -mt, then gcroot on one instance.using var context = ... and confirm the subscriber count stays at zero.NativeMemory.Alloc in a loop and compare dotnet-counters GC heap vs working set.dotnet-monitor configured with a memory-threshold trigger to collect a gcdump automatically.// Deliberate leak for practice: scoped objects subscribe to a singleton event and never unsubscribe
var monitor = new ConnectionMonitor();
for (int round = 1; round <= 20; round++)
{
for (int i = 0; i < 10_000; i++)
{
var context = new NotificationContext(monitor); // simulates a per-request object
context.Process();
// BUG: context is never disposed, so it stays subscribed
}
Console.WriteLine($"Round {round}: GC heap {GC.GetTotalMemory(forceFullCollection: true) / 1024 / 1024} MB, " +
$"subscribers {monitor.SubscriberCount}");
await Task.Delay(500);
}
Console.WriteLine($"PID {Environment.ProcessId}: collect with dotnet-gcdump / dotnet-dump now");
Console.ReadLine();
public sealed class ConnectionMonitor
{
public event EventHandler<bool>? StatusChanged;
public int SubscriberCount => StatusChanged?.GetInvocationList().Length ?? 0;
public void Raise(bool up) => StatusChanged?.Invoke(this, up);
}
public sealed class NotificationContext : IDisposable
{
private readonly ConnectionMonitor _monitor;
private readonly byte[] _state = new byte[1024]; // makes the leak visible
public NotificationContext(ConnectionMonitor monitor)
{
_monitor = monitor;
_monitor.StatusChanged += OnStatusChanged; // singleton now references this object
}
public void Process() => _state[0]++;
private void OnStatusChanged(object? sender, bool up) => _state[1] = up ? (byte)1 : (byte)0;
public void Dispose() => _monitor.StatusChanged -= OnStatusChanged; // the fix: dispose each context
}
dotnet-counters monitor -p <pid> --counters System.Runtime
dotnet-gcdump collect -p <pid> -o before.gcdump
# ...wait, then
dotnet-gcdump collect -p <pid> -o after.gcdump
dotnet-dump collect -p <pid> -o leak.dmp
dotnet-dump analyze leak.dmp
> dumpheap -stat -type NotificationContext
> dumpheap -mt <MethodTable>
> gcroot <address>
What happens: memory rises after deploy and the team panics. Why it is wrong: caches, JIT, pools and connection pools grow then plateau. Do instead: observe under steady load long enough to see whether the baseline keeps climbing.
What happens: "strings are the biggest type, so strings are the leak". Why it is wrong: one snapshot shows what's big, not what grows. Do instead: compare two or more snapshots over time.
What happens: hours spent in gcdumps while the GC heap is flat. Why it is wrong: native memory, threads and handles can grow independently. Do instead: compare GC heap with working set first.
What happens: a nightly restart or a liveness probe on memory hides the leak. Why it is wrong: it hides a bug that will eventually outgrow the restart interval and wastes capacity. Do instead: find the root chain and fix it; use restarts only as a temporary mitigation.
GC.Collect() to "fix" a leakWhat happens: forced collections are added when memory grows. Why it is wrong: leaked objects are reachable; the GC can't free them. Do instead: remove the reference that keeps them alive.
What happens: crash-dump settings are configured and the team waits. Why it is wrong: OOMKilled is SIGKILL; no dump is written. Do instead: collect on a memory threshold before the limit (dotnet-monitor rules).
What happens: the developer clears the framework collection or event at the root. Why it is wrong: the real ownership bug remains elsewhere. Do instead: fix the last link your code controls: the subscription, cache insert or scope.
What happens: Gen 2 grows and shrinks and is called a leak. Why it is wrong: objects are being collected; it's a lifetime/performance problem. Do instead: check the baseline after full GCs; use GC traces for churn.
What happens: a full dump of a large process is taken during peak traffic, with no disk space. Why it is wrong: it pauses the process and can fill the disk or crash the pod. Do instead: prefer gcdumps for discovery; take full dumps off-peak, with storage planned, on one instance out of rotation.
What happens: a change ships and the incident is closed. Why it is wrong: there may be several leaks; the fix may be incomplete. Do instead: soak test with the same counters and snapshot diff; keep a growth-rate alert.
Leak investigation is a repeatable process; seniors make it routine and safe in production.
| Practice | Benefit |
|---|---|
| Dashboards with GC heap and working set per pod | Leaks visible within hours of a deploy |
dotnet-monitor with memory-threshold triggers |
Dumps captured automatically before OOM |
| Soak tests in staging | Leaks caught before release |
| Runbook with exact commands | Faster, calmer incident response |
The fix is usually small (an unsubscribe, a bound, a scope); the skill is finding the root chain quickly.
The convention is EventHandler<TEventArgs> with object? sender and an immutable args object, EventArgs.Empty when there is no data, and a protected virtual OnXxx method that raises it. The big production risk is leaks: the publisher's delegate holds a strong reference to every subscriber, so a singleton or static publisher keeps short-lived subscribers alive forever. I find them with a heap dump and gcroot, which shows the path through the event's delegate, and fix them by unsubscribing in Dispose or using a weak event. Async handlers are async void, so I keep them thin and catch everything inside.
RAM is physical memory. Each process gets a virtual address space that the OS maps onto RAM page by page. The working set is what's resident in RAM right now; private (committed) memory is what the process owns. The GC heap is only one part: native allocations (native libraries, NativeMemory, unmanaged buffers), thread stacks, JIT code, and runtime structures add to it. In Kubernetes, the cgroup limit counts all of it, and going over gets the container OOMKilled (exit code 137), not an OutOfMemoryException. So if the GC heap is small but the pod dies, I compare GC heap size with working set in dotnet-counters. A big gap points to native memory or too many threads; a growing GC heap points to a managed leak I'd chase with dotnet-gcdump. Then I fix the cause and set limits and GC settings deliberately.
A weak reference lets you refer to an object without keeping it alive: if only weak references remain, the GC can collect it, and the reference then returns null. `WeakReference<T>` uses TryGetTarget; a short weak reference is cleared before finalisation, a long one (trackResurrection: true) only after. `ConditionalWeakTable<TKey,TValue>` attaches data to an object that lives exactly as long as the key, without leaking. Good uses: attached metadata, weak event subscriptions, and caches of expensive objects that are fine to lose. Weak references make poor general caches, because entries can disappear at the next GC, and every weak reference is a GC handle with a cost.
How to tell it (STAR)
Our [notification service] grew [200 MB a day]. I compared two gcdumps, saw request contexts piling up and used gcroot to find a singleton event holding them. Unsubscribing in Dispose fixed it, and we removed the nightly restart.
Last updated 31 Oct 2026.