Production diagnosis with dumps: when to take one, capturing with dotnet-dump, createdump settings, App Service and Kubernetes options, analysing in dotnet-dump or Visual Studio, and handling dumps as sensitive data.
When I can't attach a debugger, I take a dump: a snapshot of the process's memory, threads and stacks that I analyse offline. For a hang or a leak, dotnet-dump collect -p <pid> does it in seconds. For crashes, I enable automatic crash dumps with the DOTNET_DbgEnableMiniDump environment variables. On App Service I use Diagnose and solve problems, and in Kubernetes dotnet-monitor. Then I analyse with dotnet-dump analyze: clrstack -all and syncblk for hangs, dumpheap -stat and gcroot for leaks, pe for exceptions. Or I open the dump in Visual Studio and use the Threads and Parallel Stacks windows. Dumps contain customer data, so they're handled as sensitive.
"When I can't attach a debugger, I take a dump, a snapshot of the process's memory, threads and stacks that I analyse offline. I use dumps for hangs, leaks and crashes, and counters or traces for plain slowness. I capture with
dotnet-dump collect, automatic crash dumps through theDOTNET_DbgEnableMiniDumpsettings, App Service diagnostics ordotnet-monitorin Kubernetes, picking a heap or full dump for memory issues and a smaller one when stacks are enough, and I keep matching PDBs and the runtime version so the stacks are readable. For a hang I take two dumps a few seconds apart and compareclrstack -all, indotnet-dump analyzeor Visual Studio's Parallel Stacks, which often points to a deadlock; for a leak I compare two dumps at different sizes and follow a few object addresses back to their roots.For example, [our reporting API on App Service] grew to over [3 GB] every day and we were relying on a nightly recycle. I wanted to remove the root cause rather than keep the workaround. I took two heap dumps [six hours] apart, compared them with
dumpheap -stat, and usedgcrootto trace the growing objects to an unbounded static cache keyed by raw query strings. I replaced it with a size-limitedMemoryCachewith normalised keys, and added a memory alert and a soak test. Memory levelled off at about [600 MB] and we removed the nightly recycle.Two cautions. Collection briefly suspends the process, so I take it from one instance, out of the load balancer if I can. And dumps contain tokens and customer data, so I treat them as sensitive: restricted access, encrypted storage, deleted when the investigation ends. The mistake I see is restarting before capturing anything, which destroys the evidence, so I put the capture steps in the runbook before the incident: capture first, restart second."
| Symptom | First tool | Dump? |
|---|---|---|
| Slow but working | APM, dotnet-counters, dotnet-trace |
Rarely |
| Hang or deadlock | Counters to confirm, then dump | Yes, one or two a few seconds apart |
| Memory keeps growing | dotnet-counters (GC heap), dotnet-gcdump |
Yes for full detail, ideally two at different sizes |
| Crash (process exits) | Crash dump on failure | Yes, automatic |
dotnet-dump collect -p <pid> --type Full (or Heap for memory investigations, Mini for stacks only). Collection suspends the process briefly; a full dump of a large process can take seconds and be several GB.DOTNET_DbgEnableMiniDump=1, DOTNET_DbgMiniDumpType=4 (full; 2 is heap, 1 is mini) and DOTNET_DbgMiniDumpName=/dumps/%e.%p.dmp so the runtime writes a dump when the process crashes.dotnet-monitor as a sidecar with an authenticated endpoint, or attach an ephemeral debug container that shares the process namespace and run dotnet-dump there.In dotnet-dump analyze <file>:
clrthreads and clrstack -all: what every managed thread is doing (hangs).syncblk: Monitor locks and owners (deadlocks).dumpasync: pending async state machines (async hangs where no thread is blocked).dumpheap -stat, then dumpheap -type <Type> and gcroot <address>: what fills the heap and what keeps it alive (leaks).pe: print the current or a given exception (crashes).Or open the dump in Visual Studio (File → Open → the .dmp, then Debug with Managed Only): the Threads, Call Stack and Parallel Stacks windows work as if the process were paused, and memory analysis views show the heap.
Keep PDBs with the deployment or in a symbol server, and note the exact runtime version. Analyse on a machine with the matching runtime, or let dotnet-dump download the needed DAC files. A dump from a Linux container is best analysed with dotnet-dump (any OS) or Visual Studio.
An ASP.NET Core API on App Service Linux grew from 400 MB to 3 GB over a day and was recycled nightly. Two heap dumps six hours apart showed millions of CacheEntry objects; gcroot led to a static MemoryCache used with unbounded keys built from query strings. Adding a size limit, sliding expiry and normalised keys stopped the growth.
The interviewer is assessing production maturity: can you diagnose without a debugger, safely?
Green flags
Red flags
process memory snapshot (.dmp / core file)
+-- threads: registers + native and managed stacks
+-- managed heap: gen 0/1/2, Large Object Heap, Pinned Object Heap
| every object, its fields and its references
+-- loaded modules: DLL images and versions
+-- runtime data structures: method tables, sync blocks,
thread pool state, GC handles, finaliser queue
analysis = dump + DAC (the runtime's data access component for THAT
runtime build) + PDBs
--> readable stacks, objects, roots and exceptions
The DAC (mscordaccore) knows how to read the runtime's internal structures for one specific runtime build. That is why the runtime version matters, and why dotnet-dump and Visual Studio download the matching DAC automatically when they can.
| Symptom | Start with | Take a dump? | Dump type |
|---|---|---|---|
| Slow but working | APM, dotnet-counters, dotnet-trace |
Rarely | n/a |
| Hang or deadlock | dotnet-counters to confirm no progress |
Yes, two a few seconds apart | Mini or Full (stacks are enough; Full if you also need object values) |
| Memory keeps growing | dotnet-counters GC heap size, dotnet-gcdump |
Yes, two at different sizes | Heap or Full |
| Crash (process exits) | Automatic crash dump | Yes | Full or Heap |
| OutOfMemoryException | Crash dump plus analyzeoom |
Yes | Full |
| High CPU | dotnet-trace |
Occasionally, several in a row | Mini |
dotnet-gcdump is a lighter option for leaks: it records only types, counts, sizes and references, not object contents, so it is much smaller and holds far less sensitive data. Use a full dump when you need field values.
Type (--type / DOTNET_DbgMiniDumpType) |
Contains | Size | Use |
|---|---|---|---|
| Mini (1) | Threads, stacks, modules, minimal runtime data | Small | Hangs, crashes where stacks are enough |
| Heap (2) | Mini plus the managed heap | Large | Leaks, object state |
| Triage (3) | Mini with personal data removed as far as possible | Smallest | Sharing more widely |
| Full (4) | All process memory | Largest (roughly the working set) | When in doubt, and for native issues |
| Platform | Live process | On crash |
|---|---|---|
| Any machine with the SDK tools | dotnet-dump collect -p <pid> --type Heap |
DOTNET_DbgEnableMiniDump=1 and friends |
| Windows server | dotnet-dump, procdump -ma <pid>, or Task Manager > Create dump file |
Windows Error Reporting LocalDumps or the runtime variables |
| Azure App Service | Diagnose and solve problems > Collect Memory Dump; on Linux also dotnet-dump over SSH |
Runtime variables in app settings, writing to /home/dumps |
| Docker | docker exec into the container and run dotnet-dump (installed or copied in) |
Runtime variables, writing to a mounted volume |
| Kubernetes | dotnet-monitor sidecar sharing the diagnostics socket in /tmp, or an ephemeral debug container sharing the process namespace |
Runtime variables, writing to a persistent volume |
The crash dump variables:
| Variable | Meaning |
|---|---|
DOTNET_DbgEnableMiniDump=1 |
Write a dump when the process crashes |
DOTNET_DbgMiniDumpType=2 or 4 |
Heap or Full |
DOTNET_DbgMiniDumpName=/dumps/%e.%p.dmp |
File name; %e is the executable, %p the PID |
DOTNET_CreateDumpDiagnostics=1 |
Log what the dump writer is doing, for troubleshooting |
dotnet-dump and dotnet-monitor talk to the runtime through its diagnostics IPC channel. If the app sets DOTNET_EnableDiagnostics=0, they can't connect.
dotnet-dump collect
--> runtime suspends the process
--> dump writer copies memory to disk (seconds for a large heap)
--> process resumes
Inside dotnet-dump analyze <file>:
| Command | Question it answers |
|---|---|
clrthreads |
Which managed threads exist, and which hold locks or have exceptions |
clrstack -all |
What every managed thread is doing |
syncblk |
Which thread owns each contended Monitor lock |
dumpasync |
Which async operations are pending and what they await |
threadpool |
Thread pool state, queue length, worker counts |
dumpheap -stat |
Which types use the most memory |
dumpheap -type <Type> -short |
Addresses of objects of one type |
gcroot <address> |
What keeps an object alive |
dumpobj <address> |
Field values of one object |
eeheap -gc |
Size of each GC heap and generation |
finalizequeue |
Objects waiting for finalisation |
analyzeoom |
Why the last OutOfMemoryException happened |
pe |
The current or a given exception, with stack |
setsymbolserver -ms |
Download symbols from the Microsoft symbol server |
Or open the dump in Visual Studio (File > Open, then Debug with Managed Only). The Threads, Call Stack, Parallel Stacks and Tasks windows work as if the process were paused, and the managed memory view sorts types by size and shows paths to root.
dump A (800 MB) dump B (2.5 GB, six hours later)
dumpheap -stat dumpheap -stat
Count TotalSize Type Count TotalSize Type
120,000 490 MB Byte[] 610,000 2.4 GB Byte[]
120,000 9 MB Entry<...> 610,000 45 MB Entry<...>
|
v the type that grew most
dumpheap -type System.Byte[] -short --> pick a few addresses
gcroot 00007f3a2c01d2a8
static field LeakyCache.Entries
-> ConcurrentDictionary -> Node -> Byte[]
|
v
the holder: an unbounded static dictionary keyed by raw query strings
Common holders:
| Root | Typical cause |
|---|---|
| Static field | Unbounded cache or registry |
| Event handler | Subscribed and never unsubscribed, so the publisher keeps the subscriber alive |
| Timer | A timer callback capturing this |
| GC handle (pinned) | Buffers pinned for interop and never freed |
| Finaliser queue | Objects with finalisers created faster than the finaliser thread can run them |
| DI singleton | A singleton holding scoped services or per-request data |
clrstack -all in both. Threads with identical stacks in both are stuck; threads whose stacks changed are working.Monitor.Enter, run syncblk to find the owner. For threads in Lock.Enter (.NET 9+ System.Threading.Lock), read the stacks, because syncblk won't list it.dumpasync to find the pending async chain.threadpool to check for starvation (a long queue, all workers busy).dotnet --list-runtimes in the image, or RuntimeInformation.FrameworkDescription in a log at start-up).dotnet-dump works on any OS for dumps from any OS. A Linux core dump can be analysed on Windows and the reverse.dotnet-symbol -o ./symbols <dump> downloads matching symbols for the modules in the dump.A full dump contains everything in memory: access tokens, connection strings, decrypted payloads and customer records. Under the Privacy Act 1988 and the Australian Privacy Principles that is personal information you are responsible for.
dotnet-gcdump or Triage dumps when they are enough.dotnet-monitor only with authentication and never publicly.The dump format and the diagnostics tools are stable and versioned with the runtime. Update dotnet-dump and dotnet-monitor alongside the runtime, and check the .NET 11 diagnostics release notes for new analysis commands.
dotnet-dump collect -p <pid> --type Heap -o a.dmp, wait 30 seconds, and collect b.dmp.dumpheap -stat and compare the System.Byte[] counts.dumpheap -type System.Byte[] -short, then gcroot on one address to find the static field.clrstack -all and threadpool to see what threads are doing.b.dmp in Visual Studio, Debug with Managed Only, and compare the experience.dotnet-gcdump collect -p <pid> and compare its size with the heap dump.A small app that leaks through a static cache, to practise on:
using System.Collections.Concurrent;
Console.WriteLine($"PID {Environment.ProcessId}: run dotnet-dump collect -p {Environment.ProcessId} --type Heap");
var requestId = 0;
while (true)
{
var key = $"report?customer={Random.Shared.Next()}&page={requestId++}";
LeakyCache.GetOrAdd(key);
if (requestId % 10_000 == 0)
{
Console.WriteLine($"{requestId} requests, {GC.GetTotalMemory(false) / 1_048_576} MB");
}
await Task.Delay(1);
}
public static class LeakyCache
{
// Unbounded, keyed by raw query strings: grows forever.
private static readonly ConcurrentDictionary<string, byte[]> Entries = new();
public static byte[] GetOrAdd(string key) => Entries.GetOrAdd(key, _ => new byte[4_096]);
}
Capture and analyse:
dotnet tool install --global dotnet-dump
dotnet-dump collect -p 12345 --type Heap -o ./dumps/api-1.dmp
dotnet-dump analyze ./dumps/api-1.dmp
> dumpheap -stat
> dumpheap -type System.Collections.Concurrent.ConcurrentDictionary -short
> gcroot 00007f3a2c01d2a8
> clrstack -all
> pe
Automatic crash dumps (container or App Service environment variables):
DOTNET_DbgEnableMiniDump=1
DOTNET_DbgMiniDumpType=4
DOTNET_DbgMiniDumpName=/home/dumps/%e.%p.dmp
The fixed cache with a size limit and expiry (Microsoft.Extensions.Caching.Memory):
using Microsoft.Extensions.Caching.Memory;
var cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 10_000 });
byte[] GetReport(string normalisedKey) =>
cache.GetOrCreate(normalisedKey, entry =>
{
entry.Size = 1;
entry.SlidingExpiration = TimeSpan.FromMinutes(10);
return new byte[4_096];
})!;
Console.WriteLine(GetReport("report:customer:42:page:1").Length);
Mistake: restarting the process before capturing anything. The restart clears the hang or the leak and destroys the evidence, so the problem returns with nothing learnt. Instead: capture first, restart second. Have the capture steps in the runbook so on-call staff can do it in minutes.
Mistake: taking a single dump of a hang. One snapshot can't tell a stuck thread from a busy one that happened to be at that line. Instead: take two dumps a few seconds apart and compare the stacks.
Mistake: using a mini dump for a leak.
A mini dump doesn't contain the managed heap, so dumpheap and gcroot have nothing to read.
Instead: use --type Heap or Full, or dotnet-gcdump when types and references are enough.
Mistake: comparing only one leak dump.
A big heap at one moment may be normal; what matters is what grows.
Instead: take two dumps at different memory sizes and compare dumpheap -stat output.
Mistake: analysing without matching PDBs or runtime.
Stacks show addresses instead of method names and lines, or the tool can't read runtime structures.
Instead: keep PDBs for every release, record the runtime version, and let dotnet-dump download the matching DAC and symbols.
Mistake: taking a full dump of a large process at peak without warning anyone. Collection pauses the process for seconds, can fail health checks and can fill the disk. Instead: tell the team, take it from one instance (out of the load balancer if possible), and check disk space first.
Mistake: expecting syncblk to show every lock.
It lists only contended Monitor locks, not System.Threading.Lock, SemaphoreSlim or async waits.
Instead: read clrstack -all and dumpasync as well.
Mistake: disabling diagnostics in production images.
With DOTNET_EnableDiagnostics=0, dotnet-dump and dotnet-monitor can't connect when you need them.
Instead: keep diagnostics enabled and control access to the process and the diagnostics socket instead.
Mistake: attaching dumps to tickets, chat or email. A dump can contain tokens, connection strings and customer records. Instead: store dumps encrypted with restricted access and short retention, and share the findings, not the file.
Mistake: exposing dotnet-monitor without authentication.
Anyone who can reach it can download dumps of your process.
Instead: require authentication, keep it on an internal network, and never expose it publicly.
Seniors prepare for dumps before the incident. They decide in advance how dumps are captured in each hosting platform, who may capture them, where they go (encrypted, in-region storage with short retention) and how PDBs are kept for each release, and they write that into the runbook. During an incident they capture first and restart second, take paired dumps for hangs and two-point dumps for leaks, and then turn the finding into a guard: a bounded cache, a lifetime fix, a memory alert in Application Insights, a load test that checks steady-state memory. They can also explain the cost: a dump briefly freezes the process, so on a multi-instance service they take it from one instance after removing it from the load balancer where possible.
When it hangs, I press Break All so every thread stops where it is. In the Threads window I look for threads sitting in Monitor.Enter, Lock.Enter or Task.Wait, and switch to each one to read its call stack. Parallel Stacks shows all thread stacks merged into one graph, so two threads each holding a lock and waiting for the other's lock stand out immediately. That's the classic opposite-order deadlock. In production I can't attach, so I take a dump with dotnet-dump and run clrstack -all and syncblk. The fix is a consistent lock order, smaller locks, or removing sync-over-async.
The Memory window shows the raw bytes at an address in the paused process, as hex with a text column. In .NET I use it when the bytes themselves are the question: a binary protocol parsed wrongly because of endianness, a UTF-8 versus UTF-16 encoding bug, a struct layout passed to native code, or a buffer in unsafe code. I type a pointer or address expression to jump there. It is not how you find memory leaks. For those I use the Memory Usage tool's heap snapshots and diffs, .NET Object Allocation Tracking, or dotnet-gcdump and dotnet-dump in production.
How to tell it (STAR)
**Situation:** Our [reporting API] grew to over 3 GB every day and we were relying on a nightly restart. **Task:** I wanted to remove the root cause instead of the workaround. **Action:** I took two heap dumps hours apart, compared them with `dumpheap -stat`, and used `gcroot` to trace the growing objects to an unbounded static cache keyed by raw query strings. I replaced it with a size-limited `MemoryCache` with normalised keys, and added a memory alert and a soak test. **Result:** Memory levelled off at about [600 MB] and we removed the nightly recycle.
Last updated 1 Nov 2026.