📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

πŸ“š Interview question

How do you investigate a memory leak in a .NET service in production, step by step?

Confirming a leak, managed versus native growth, dotnet-counters, dotnet-gcdump comparisons, dotnet-dump and gcroot, common leak sources and verifying the fix.

Senior Debugging Advanced diagnostics dotnet-dump dotnet-gcdump memory-leak production

🎯 Key points

  • Confirm a leak as steady growth under constant load, not warm-up
  • Compare GC heap size with working set to separate managed from native growth
  • Take two dotnet-gcdump snapshots over time and compare growing types
  • Use dotnet-dump with dumpheap and gcroot to find the root chain
  • Common roots: static caches, events, timers, DI root transients, closures, unbounded queues
  • Use dotnet-monitor or sidecars to collect diagnostics in containers
  • Verify the fix with the same metrics and a soak test

⏱ 30-second answer

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.

πŸ—£ Full answer (senior)

How I would answer this in an interview

"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-gcdump snapshots some time apart and see which types grow. Four: take a dotnet-dump, find instances of the growing type and run gcroot to 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 with dotnet-monitor or a debug container, and I capture data before the OOM killer strikes, because a killed pod leaves nothing to analyse."


Step 1: Confirm and characterise

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.

Step 2: Managed or native?

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)

Step 3: The managed leak workflow

  1. dotnet-gcdump collect -p <pid> -o before.gcdump (small file; induces a Gen 2 GC).
  2. Keep load running for 30–60 minutes (or N operations); collect after.gcdump.
  3. Open both in Visual Studio or PerfView and diff: sort by count/size growth, ignore types that only fluctuate.
  4. dotnet-dump collect -p <pid> for a full dump (pauses the process; size β‰ˆ process memory).
  5. 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.
  6. Read the chain from the root down and find the last link your code controls.

Step 4: Common culprits

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

Step 5: Fix and verify

  • Fix the root cause, not the symptom (no scheduled restarts, no GC.Collect).
  • Re-run the same load and compare the same counters and a new pair of gcdumps.
  • Add a soak test and an alert on GC heap growth rate per pod.

Production example

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.

πŸ” What the interviewer is checking

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.

πŸ“š Learn in depth

1. Choosing the right tool

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

2. gcdump vs full dump

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.

3. Reading a 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.

4. Useful SOS commands

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

5. Native leak investigation

  1. Compare eeheap -gc (managed) with total process memory (maddress -summary or OS tools).
  2. Linux: pmap -x <pid>, /proc/<pid>/smaps; Windows: VMMap, RAMMap.
  3. Look for growth in anonymous native regions; correlate with native libraries (compression, image processing, SQL native drivers, gRPC).
  4. Check that every SafeHandle owner is disposed; check thread count.
  5. For glibc allocator fragmentation in many-threaded apps, consider MALLOC_ARENA_MAX.

6. Kubernetes practicalities

  • Collecting: 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).
  • Disk space: a 4 GB process produces a ~4 GB dump; mount a volume.
  • OOMKilled leaves no dump: the kernel sends SIGKILL, so crash-dump settings don't fire. Use dotnet-monitor collection rules on a memory threshold (e.g. GC heap > 80% of limit) to capture data before the kill.
  • Crash dumps for managed crashes: DOTNET_DbgEnableMiniDump=1, DOTNET_DbgMiniDumpType, DOTNET_DbgMiniDumpName.
  • Security: dumps contain secrets and PII; handle and delete them accordingly.

7. Version notes

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

8. Try it yourself

  1. Run the code example; watch subscriber count and heap grow each round.
  2. Collect two gcdumps between rounds and diff them in Visual Studio or PerfView.
  3. Collect a full dump; run dumpheap -stat -type NotificationContext, dumpheap -mt, then gcroot on one instance.
  4. Fix the leak with using var context = ... and confirm the subscriber count stays at zero.
  5. Simulate a native leak with NativeMemory.Alloc in a loop and compare dotnet-counters GC heap vs working set.
  6. Run the leaky app in a container with a memory limit and dotnet-monitor configured with a memory-threshold trigger to collect a gcdump automatically.

πŸ’» Code

// 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>

⚠ Common mistakes

1. Calling a warming cache a leak

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.

2. Taking one snapshot and guessing

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.

3. Assuming every leak is managed

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.

4. Restarting as the fix

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.

5. Using GC.Collect() to "fix" a leak

What 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.

6. Waiting for the OOM kill to collect data

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).

7. Fixing the wrong link in the chain

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.

8. Confusing mid-life churn with a leak

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.

9. Taking full dumps carelessly in production

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.

10. Not verifying the fix

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.

πŸ› Senior insight: trade-offs

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.

πŸ”— Follow-ups the interviewer may ask

  1. When: Candidate finds an event subscription in the root chain Why: Checks event handler leak fixes
    How to answer

    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.

  2. When: Candidate mentions pods being OOMKilled Why: Checks container memory diagnosis
    How to answer

    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.

  3. When: Candidate suggests weak references as a fix Why: Checks when weak references are appropriate
    How to answer

    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.

πŸ›  Real-world problems: how you handle them

Scoped objects leaked through a singleton event
Situation
A notification service grew by [about 200 MB per day] and was restarted nightly.
Signals
GC heap and Gen 2 size grew steadily with working set; no plateau.
Investigation
Two `dotnet-gcdump` snapshots an hour apart showed `NotificationContext` counts growing; `gcroot` showed the singleton `ConnectionMonitor.StatusChanged` event holding them.
Resolution
Unsubscribed in `Dispose` and ensured contexts were scoped and disposed by DI.
Prevention
Added a memory soak test and an alert on GC heap growth rate per pod.

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.

↩ Usually asked after

More in Garbage Collection

Last updated 31 Oct 2026.

🌐 Sangam's Webring

βͺ Previous 🏠 Main Next ⏩
πŸ’¬ Chat with me