How the call stack overflows, why .NET terminates the process instead of letting you catch it, common causes (unbounded or deep recursion, self-referencing properties, cyclic object graphs, large stackalloc), and how to diagnose with crash output, dumps and tests.
Each thread has a fixed-size stack (about 1 MB on Windows), and every method call pushes a frame. Unbounded or very deep recursion exhausts it. Since .NET 2.0 the runtime terminates the process on a stack overflow instead of throwing a catchable exception, because with no stack left it can't safely run handlers or finally blocks. To diagnose it, I read the crash output (recent .NET versions print the repeating frames to standard error), check container logs or the Windows Event Log, and capture a crash dump with DOTNET_DbgEnableMiniDump=1 to inspect the stack. Typical causes are a missing base case, a property or ToString that calls itself, cycles in object graphs (serialisation, Equals), and deeply nested input. Fixes: correct the recursion, switch to iteration with an explicit stack, and add depth limits and tests.
ulimit -s size).Handling an exception needs stack space to run the handler, finally blocks and the exception machinery itself. When the stack is exhausted, the runtime can't guarantee any of that will work, so it fails fast: it reports the overflow and terminates the process. try/catch, AppDomain.UnhandledException and finally blocks don't run. The only real defence is to never overflow, and your hosting (Kubernetes, App Service, a Windows service manager) restarts the process.
| Cause | Example |
|---|---|
| Missing or wrong base case | Factorial(n) that never reaches 0 for negative input |
| Property calling itself | public string Name { get => Name; } instead of a backing field |
| Mutual recursion | A() calls B() calls A() under a rare condition |
Equals, GetHashCode, ToString over cyclic graphs |
Parent references child, child references parent, both include each other |
| Serialising cyclic graphs with a custom recursive serialiser | Order → Customer → Orders → ... |
| Deeply nested input | A tree, JSON or XML nested tens of thousands of levels |
Large stackalloc |
stackalloc byte[length] with an attacker-controlled length |
| Event handler loops | A setter raises PropertyChanged whose handler sets the property again |
kubectl logs --previous; on App Service, the log stream or eventlog.xml; on Windows, the Application event log (.NET Runtime source).DOTNET_DbgEnableMiniDump=1, DOTNET_DbgMiniDumpType=4 (full) or 2 (with heap), and DOTNET_DbgMiniDumpName=/dumps/crash-%p.dmp. Then dotnet-dump analyze and run clrstack on the faulting thread, or open it in Visual Studio.Stack<T> or Queue<T>, which lives on the heap and is limited only by memory.HashSet<T> with ReferenceEqualityComparer.Instance) when walking graphs that may contain cycles.JsonSerializerOptions.MaxDepth (default 64), XmlReaderSettings limits, your own depth counter.stackalloc sizes and fall back to ArrayPool<T>.new Thread(work, maxStackSize: 16 * 1024 * 1024)).An API that evaluated user-defined rule trees crashed and restarted several times a day. kubectl logs --previous showed thousands of repeated RuleEvaluator.Evaluate frames. A customer had built a rule that referenced itself through an alias. The fix detected cycles when saving rules, limited nesting depth to 50, and rewrote evaluation to use an explicit stack.
Checks understanding of the stack's limits and practical crash diagnosis.
Green flags: explains frames and fixed stack size, knows it's uncatchable and why, lists realistic causes, describes crash output, dumps and logs, fixes with iteration, cycle detection and depth limits, sees the DoS angle.
Red flags: suggests catching it; only suggests a bigger stack; no plan for getting evidence from production.
high addresses
+-----------------------------+
| Main |
| Handle(request) |
| Evaluate(rule1) |
| Evaluate(rule2) |
| Evaluate(rule1) <- cycle |
| Evaluate(rule2) |
| ... |
| Evaluate(rule1) |
+-----------------------------+ <- guard page touched: stack overflow, process terminated
low addresses
stack size / frame size ≈ max depth
1 MB / 100 bytes per frame ≈ 10,000 levels
1 MB / 1 KB per frame ≈ 1,000 levels (big structs or stackalloc in the frame)
Optimised (Release) frames are often smaller than Debug frames, so a recursion that overflows in Debug might survive in Release, and vice versa if inlining changes. Never rely on "it worked on my machine".
// Recursive depth-first traversal
void Visit(Node node) { Process(node); foreach (var child in node.Children) Visit(child); }
// Iterative with an explicit stack (heap-allocated, no depth limit beyond memory)
void VisitIterative(Node root)
{
var stack = new Stack<Node>();
stack.Push(root);
while (stack.TryPop(out var node))
{
Process(node);
for (var i = node.Children.Count - 1; i >= 0; i--) stack.Push(node.Children[i]);
}
}
C# doesn't guarantee tail-call elimination, so rewriting as tail recursion isn't a reliable fix.
env:
- name: DOTNET_DbgEnableMiniDump
value: "1"
- name: DOTNET_DbgMiniDumpType
value: "2" # 1 mini, 2 with heap, 3 triage, 4 full
- name: DOTNET_DbgMiniDumpName
value: /dumps/%e-%p-%t.dmp
volumeMounts:
- name: dumps
mountPath: /dumps # persistent or shared volume so the dump survives the restart
Dumps can contain personal information from memory; store them securely and delete them after analysis (Privacy Act and your organisation's data handling rules apply).
Any recursion driven by user input is a potential denial-of-service: one crafted request crashes the whole process and every in-flight request with it. Depth limits on parsers and validators are a security control, not just a robustness one. System.Text.Json's MaxDepth exists for this reason.
| Symptom | Cause |
|---|---|
StackOverflowException, process killed |
Stack exhausted |
OutOfMemoryException |
Heap allocation failed |
InsufficientExecutionStackException |
Thrown by RuntimeHelpers.EnsureSufficientExecutionStack() when stack is nearly exhausted: catchable, used by compilers and parsers as a guard |
int Recurse(int n) => Recurse(n + 1) + 1;, run it, and read the output on your .NET version.DOTNET_DbgEnableMiniDump=1 and the dump name, run again, and analyse the dump with dotnet-dump analyze and clrstack.RuntimeHelpers.EnsureSufficientExecutionStack() at the top of a recursive tree evaluator and test it with a 100,000-level tree.namespace StackOverflowFixes;
using System.Runtime.CompilerServices;
using System.Text.Json;
public sealed class RuleNode
{
public required string Name { get; init; }
public List<RuleNode> Children { get; } = [];
}
public static class RuleEvaluator
{
public const int MaxDepth = 50;
// Iterative, cycle-safe and depth-limited evaluation.
public static int CountNodes(RuleNode root)
{
var visited = new HashSet<RuleNode>(ReferenceEqualityComparer.Instance);
var stack = new Stack<(RuleNode Node, int Depth)>();
stack.Push((root, 0));
int count = 0;
while (stack.TryPop(out var item))
{
if (item.Depth > MaxDepth)
{
throw new InvalidOperationException($"Rule nesting exceeds {MaxDepth} levels.");
}
if (!visited.Add(item.Node))
{
throw new InvalidOperationException($"Rule '{item.Node.Name}' is part of a cycle.");
}
count++;
foreach (var child in item.Node.Children)
{
stack.Push((child, item.Depth + 1));
}
}
return count;
}
// If you must recurse, guard it: throws a catchable InsufficientExecutionStackException.
public static int Depth(RuleNode node)
{
RuntimeHelpers.EnsureSufficientExecutionStack();
int max = 0;
foreach (var child in node.Children) max = Math.Max(max, Depth(child));
return max + 1;
}
}
public static class SafeJson
{
// Limit nesting of untrusted JSON (the default is 64; lower it if your schema allows).
public static readonly JsonSerializerOptions Options = new() { MaxDepth = 32 };
}
public sealed class Customer
{
private string _name = "";
// BUG (commented out): the getter calls itself and overflows the stack.
// public string Name { get => Name; set => _name = value; }
public string Name { get => _name; set => _name = value; }
}
Mistake: Wrapping the code in try/catch (StackOverflowException).
Why it is wrong: the handler never runs; the process is terminated, and the false sense of safety hides the real bug.
Instead: prevent the overflow: fix recursion, limit depth, or use iteration.
Mistake: Increasing the stack size as the fix. Why it is wrong: a bigger stack only delays the crash for infinite recursion, and wastes memory on every thread if applied broadly. Instead: find the cause; use a dedicated large-stack thread only for genuinely deep, bounded work.
Mistake: Assuming async recursion is safe because it "returns to the thread pool".
Why it is wrong: an await on an already-completed task continues synchronously on the same stack, so a recursive async method over in-memory or cached data can overflow just like synchronous recursion. Continuations that run inline (for example TaskContinuationOptions.ExecuteSynchronously chains) can also build deep stacks.
Instead: make deep async work iterative (a loop over a queue), or bound the depth.
Mistake: Losing the evidence on restart. Why it is wrong: containers restart immediately, so crash output and memory state disappear. Instead: check previous container logs, enable crash dumps to a persistent volume, and record the triggering request ID.
Mistake: Recursing over untrusted input without limits.
Why it is wrong: a deeply nested payload becomes a one-request denial-of-service that kills every in-flight request.
Instead: set parser depth limits (MaxDepth), validate input depth, and prefer iterative algorithms.
Mistake: Walking object graphs without cycle detection.
Why it is wrong: custom Equals, ToString, cloning or serialisation over parent-child references loops forever.
Instead: track visited objects with ReferenceEqualityComparer.Instance, or design graphs and DTOs without cycles.
Mistake: Relying on tail-call optimisation.
Why it is wrong: C# does not guarantee it, and behaviour differs between Debug, Release and platforms.
Instead: convert deep recursion to an explicit loop with a Stack<T> or Queue<T>.
A stack overflow is unusual among .NET failures because it takes down the whole process with no chance to clean up, so the senior response is about prevention and evidence.
| Stage | What a senior sets up | Tool |
|---|---|---|
| Prevent | Iterative tree and graph walks, cycle detection, depth limits on all external input | Stack<T>, ReferenceEqualityComparer, MaxDepth |
| Guard | Catchable failure for legitimately deep recursion | RuntimeHelpers.EnsureSufficientExecutionStack() |
| Detect | Alert on pod or process restarts and crash loops | Kubernetes restart metrics, App Service health checks |
| Capture | Crash dumps to persistent, access-controlled storage | DOTNET_DbgEnableMiniDump, createdump |
| Analyse | Repeating frames and the poison input | dotnet-dump analyze + clrstack, request-log correlation |
| Test | Deep, cyclic and malformed inputs in CI | Property-based tests, fuzzing parsers |
Dumps can contain personal data from memory, so they need secure storage and deletion after analysis. Profilers such as PerfView or dotTrace help with performance, but for an overflow the dump and the crash output are the primary evidence.
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.
The stack is a per-thread region that holds method frames: locals, arguments, return addresses and saved registers. Allocating is just moving the stack pointer, and everything is freed when the method returns. The heap is shared memory for objects whose lifetime is not tied to one method; the generational garbage collector frees them once they are unreachable.
The common line "value types live on the stack, reference types on the heap" is only half true. Storage follows the declaration: a value-type local usually lives on the stack (or in a register), but a value-type field lives inside its object on the heap, and boxing, lambdas that capture locals, iterators and async methods move data to the heap. Reference-type objects are conceptually heap objects (only the reference sits on the stack), although the current JIT's escape analysis can place a small object that provably never leaves the method on the stack as an optimisation. The language guarantees lifetime and copy semantics, not a location.
How to tell it (STAR)
**Situation:** At [company], our pricing API pods were crashing and restarting [several times a day], and each crash dropped every in-flight request. **Task:** I had to find the cause and stop it happening again. **Action:** The previous container logs showed a stack overflow with repeating `Evaluate` frames. I enabled crash dumps, confirmed the cycle in a dump, and matched the timing to a customer rule that referenced itself through an alias. I added cycle detection and a depth limit when rules are saved, and rewrote evaluation to be iterative. **Result:** The crashes stopped completely, and the property-based tests we added later caught [N] other unsafe rule shapes before release.
Last updated 23 Oct 2026.