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

📚 Interview question

A .NET service suddenly crashes with a StackOverflowException. Why can't you catch it, and how do you find and fix the cause?

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.

Mid-Level Debugging Intermediate crash-dump debugging memory recursion stack stack-overflow

🎯 Key points

  • Each thread's stack is fixed when the thread starts (1 MB default on Windows; 1.5 MB for .NET secondary threads on Linux; the Linux main thread uses ulimit -s, often 8 MB) and every call pushes a frame whose size depends on parameters, locals and JIT optimisation
  • A stack overflow cannot be caught in modern .NET: with no stack left the runtime cannot safely run handlers or finally blocks, so it fails fast and terminates the process
  • Common causes: missing recursion base case, a property or ToString calling itself, cycles in object graphs (Equals, cloning, custom serialisers), deeply nested input, large or input-sized stackalloc, and recursive async methods whose awaits complete synchronously
  • The runtime writes 'Stack overflow.' and the repeating frames to stderr; read them via kubectl logs --previous, App Service log stream or the Windows Application event log
  • Capture a crash dump before you need it (DOTNET_DbgEnableMiniDump with a persistent dump path, or createdump) and inspect the faulting thread with dotnet-dump clrstack or Visual Studio
  • Fix by correcting recursion, converting deep recursion to iteration with an explicit Stack<T>, detecting cycles with ReferenceEqualityComparer, and limiting input depth
  • RuntimeHelpers.EnsureSufficientExecutionStack gives legitimately deep recursion a catchable InsufficientExecutionStackException instead of a crash
  • Treat recursion over untrusted input as a denial-of-service risk: depth limits (JsonSerializerOptions.MaxDepth, XML reader limits) are a security control

⏱ 30-second answer

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.

🗣 Full answer (senior)

Why the stack overflows

  • The stack is reserved per thread at creation (about 1 MB on Windows; secondary threads on Linux default to 1.5 MB; the main thread on Linux uses the ulimit -s size).
  • Each call uses a frame: arguments, locals, saved registers, return address, often 50–200+ bytes. Recursion 10,000–100,000 levels deep is enough to exhaust it.
  • A guard page at the end of the stack triggers the overflow.

Why you can't catch it

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.

Common causes

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

Diagnosing

  1. Read the crash output. Recent .NET versions write "Stack overflow." followed by the repeating frames to stderr. In Kubernetes, check kubectl logs --previous; on App Service, the log stream or eventlog.xml; on Windows, the Application event log (.NET Runtime source).
  2. Capture a dump on crash: set 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.
  3. Reproduce locally under a debugger: Visual Studio breaks on the overflow, and the Call Stack window shows the cycle.
  4. Find the input: correlate the crash time with request logs to identify the payload or record that triggered the deep path.

Fixing

  • Correct the base case or the self-referencing member.
  • Convert deep recursion to iteration with an explicit Stack<T> or Queue<T>, which lives on the heap and is limited only by memory.
  • Track visited nodes (HashSet<T> with ReferenceEqualityComparer.Instance) when walking graphs that may contain cycles.
  • Enforce depth limits on untrusted input: JsonSerializerOptions.MaxDepth (default 64), XmlReaderSettings limits, your own depth counter.
  • Cap stackalloc sizes and fall back to ArrayPool<T>.
  • As a last resort for legitimately deep work, run it on a dedicated thread with a larger stack (new Thread(work, maxStackSize: 16 * 1024 * 1024)).

Production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. The stack filling up

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

2. Estimating depth limits

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

3. Recursion to iteration

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

4. Dump configuration for containers

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

5. Security angle

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.

6. Related but different

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

7. Try it yourself

  1. Write int Recurse(int n) => Recurse(n + 1) + 1;, run it, and read the output on your .NET version.
  2. Set DOTNET_DbgEnableMiniDump=1 and the dump name, run again, and analyse the dump with dotnet-dump analyze and clrstack.
  3. Add RuntimeHelpers.EnsureSufficientExecutionStack() at the top of a recursive tree evaluator and test it with a 100,000-level tree.
  4. Rewrite the evaluator iteratively and confirm it handles the same tree.

💻 Code

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; }
}

⚠ Common mistakes

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

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate proposes capturing a crash dump. Why: Checks the candidate's practical dump-analysis skills.
    How to answer

    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.

  2. When: Candidate explains frames being pushed per call. Why: Checks the underlying model of stack frames.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Self-referencing customer rules crash the rules API several times a day
Situation
A multi-tenant pricing API let customers define nested discount rules that could reference other rules by alias. Pods in AKS restarted several times a day, dropping all in-flight requests each time.
Signals
Kubernetes restart counts climbing, 502s from the ingress during restarts, and `kubectl logs --previous` ending with "Stack overflow." followed by thousands of repeated `RuleEvaluator.Evaluate` frames.
Investigation
Enabled crash dumps to a persistent volume, analysed one with `dotnet-dump analyze` and `clrstack`, and correlated the crash time with request logs to find the tenant and rule ID. The rule referenced itself through an alias two levels down.
Resolution
Added cycle detection and a nesting limit of 50 when rules are saved, rewrote evaluation iteratively with an explicit stack and visited set, and fixed the tenant's rule with their agreement.
Prevention
Property-based tests generating deep and cyclic rule trees, depth limits on all user-supplied structures, crash dumps kept enabled with secure storage, and an alert on pod restarts.

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.

↩ Usually asked after

More in Memory

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me