Recursion fundamentals: base and recursive cases, how the call stack works, StackOverflowException in .NET, and converting recursion to iteration with an explicit stack.
Recursion is a function solving a problem by calling itself on a smaller version of it. Every recursive function needs:
Each call pushes a frame on the thread's call stack (about 1 MB by default on Windows). Too deep and you get a StackOverflowException, which cannot be caught and kills the process. Recursion fits naturally recursive data (trees, nested JSON, folders), but use a loop with an explicit Stack<T> or Queue<T> when depth can be large or is controlled by input.
static long Factorial(int n)
{
if (n <= 1) return 1; // base case
return n * Factorial(n - 1); // recursive case: smaller problem
}
When Factorial(4) runs, frames stack up until the base case, then unwind:
Factorial(4) waits for Factorial(3)
Factorial(3) waits for Factorial(2)
Factorial(2) waits for Factorial(1)
Factorial(1) returns 1
returns 2 * 1 = 2
returns 3 * 2 = 6
returns 4 * 6 = 24
Each frame holds the return address, arguments and locals. The default stack is about 1 MB for threads on Windows and typically 1.5 MB for secondary threads on Linux with .NET. Real frames are often 100+ bytes, so depth in the tens of thousands can overflow. In .NET a StackOverflowException cannot be caught: the runtime terminates the process, so an ASP.NET Core app crashes and restarts, dropping all in-flight requests.
A category API built breadcrumbs by recursing up ParentId. A data-migration bug created a cycle (A → B → A), recursion never reached a base case, and the process died with a stack overflow. Every pod crashed in a loop because each one received the same request. The fix was an iterative walk with a HashSet of visited ids and a maximum depth, plus a database check constraint to prevent the cycle.
Checks understanding of recursion mechanics and its failure modes in a real runtime.
Green flags
StackOverflowException cannot be caught in .NET.Stack<T> loop correctly.Red flags
Break rule 2 (cycles, n not decreasing, negative input to factorial) and you get infinite recursion.
high addresses
+------------------------+
| Main frame |
+------------------------+
| Walk(root) frame | return address, args, locals, saved registers
+------------------------+
| Walk(child) frame |
+------------------------+
| Walk(grandchild) frame | <- stack pointer moves down as calls nest
+------------------------+
| ... guard page ... | touching this = stack overflow
low addresses
Async methods are different: await in a recursive async method stores state in heap-allocated state machines, but synchronous completion still runs nested on the stack, so very deep async recursion can still overflow.
Any recursion can be turned into a loop with an explicit stack that lives on the heap:
recursive DFS: iterative DFS:
Visit(node): stack.Push(root)
process(node) while stack not empty:
for child in children: node = stack.Pop()
Visit(child) process(node)
push children (reverse order to keep same order)
Swap the Stack<T> for a Queue<T> and you get breadth-first order instead.
static long SumTo(long n, long acc = 0) => n == 0 ? acc : SumTo(n - 1, acc + n);
This is tail recursive (the call is the last action), and the x64 JIT sometimes turns such calls into jumps, but the C# language and runtime do not guarantee it (it differs by platform, debug versus release, and method shape). Never rely on it; write the loop.
Use a recurrence:
| Code | Recurrence | Result |
|---|---|---|
| Factorial | T(n) = T(n−1) + O(1) | O(n) time, O(n) stack |
| Binary search | T(n) = T(n/2) + O(1) | O(log n) |
| Merge sort | T(n) = 2T(n/2) + O(n) | O(n log n) |
| Naive Fibonacci | T(n) = T(n−1) + T(n−2) + O(1) | O(φⁿ) ≈ O(1.618ⁿ) |
| Tree walk | each node once | O(nodes), stack O(height) |
System.Text.Json has JsonSerializerOptions.MaxDepth (default 64) so a deeply nested payload cannot blow your stack.RuntimeHelpers.EnsureSufficientExecutionStack() throws a catchable InsufficientExecutionStackException before you overflow; Roslyn and Json use this idea.// 1) Watch the stack overflow (process will crash; run in a throwaway console app)
// static int Down(int n) => Down(n + 1); Down(0);
// 2) Safe iterative folder size
long total = 0;
var pending = new Stack<string>();
pending.Push(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
while (pending.Count > 0)
{
var dir = pending.Pop();
try
{
foreach (var f in Directory.EnumerateFiles(dir)) total += new FileInfo(f).Length;
foreach (var d in Directory.EnumerateDirectories(dir)) pending.Push(d);
}
catch (UnauthorizedAccessException) { /* skip */ }
}
Console.WriteLine($"{total / 1_048_576} MB");
Note that Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories) already does this, but writing it yourself shows the pattern.
public sealed record Category(int Id, string Name, List<Category> Children);
// Recursive: clear, depth = tree height
static void PrintRecursive(Category node, int depth = 0)
{
Console.WriteLine($"{new string(' ', depth * 2)}{node.Name}");
foreach (var child in node.Children)
PrintRecursive(child, depth + 1);
}
// Iterative: same order, stack on the heap, cannot overflow the thread stack
static void PrintIterative(Category root)
{
var stack = new Stack<(Category Node, int Depth)>();
stack.Push((root, 0));
while (stack.Count > 0)
{
var (node, depth) = stack.Pop();
Console.WriteLine($"{new string(' ', depth * 2)}{node.Name}");
for (int i = node.Children.Count - 1; i >= 0; i--) // reverse to keep left-to-right order
stack.Push((node.Children[i], depth + 1));
}
}
// Walking up a parent chain safely: cycle detection and depth cap
static List<string> Breadcrumbs(int id, IReadOnlyDictionary<int, (string Name, int? ParentId)> all, int maxDepth = 50)
{
var path = new List<string>();
var visited = new HashSet<int>();
int? current = id;
while (current is int c)
{
if (!visited.Add(c)) throw new InvalidOperationException($"Cycle at category {c}");
if (path.Count == maxDepth) throw new InvalidOperationException("Hierarchy too deep");
var (name, parent) = all[c];
path.Add(name);
current = parent;
}
path.Reverse();
return path;
}
Mistake: Missing or unreachable base case. Why it is wrong: The function recurses until the stack overflows, which in .NET terminates the whole process. Instead: Write the base case first, and check that every recursive call shrinks the input (including edge inputs such as negative numbers).
Mistake: Wrapping recursion in catch (StackOverflowException).
Why it is wrong: Since .NET 2.0 the runtime does not let user code handle a stack overflow; the catch block never runs and the process dies.
Instead: Prevent it: cap depth, call RuntimeHelpers.EnsureSufficientExecutionStack() (it throws a catchable InsufficientExecutionStackException), or iterate with an explicit stack.
Mistake: Recursing over data that may contain cycles, such as parent links or graph edges.
Why it is wrong: A cycle means the base case is never reached.
Instead: Track visited ids in a HashSet<int> and fail clearly when a cycle is found; also prevent cycles in the database.
Mistake: Relying on tail-call optimisation.
Why it is wrong: C# and the JIT do not guarantee it; behaviour differs between Debug and Release builds and between platforms.
Instead: Turn tail recursion into a while loop.
Mistake: Recomputing overlapping subproblems, as in naive Fibonacci. Why it is wrong: The number of calls grows exponentially, so even small inputs become very slow. Instead: Memoise results or use bottom-up dynamic programming.
Mistake: Recursing to a depth that the caller controls, such as nested JSON, XML or expressions, without validation.
Why it is wrong: An attacker can send one deeply nested payload and crash the service.
Instead: Validate input and enforce a maximum depth (for example JsonSerializerOptions.MaxDepth, default 64), or use an iterative algorithm.
A senior engineer chooses recursion for clarity on bounded, naturally recursive data and switches to iteration when depth is unbounded or controlled by input. The deciding fact in .NET is that a stack overflow is uncatchable and kills the process. In a web service, one bad payload or one corrupted hierarchy can become a crash loop across every pod, because each pod receives the same retried request.
| Situation | Choice | Reason |
|---|---|---|
| Balanced tree, depth in the tens | Recursion | Clear code, stack use O(height) is tiny |
| Depth from user input or external data | Iteration with Stack<T> or Queue<T> |
Heap memory, depth limited only by RAM |
| Parent chains that might contain cycles | Loop with visited set and depth cap | Turns a crash into a clear error |
| Overlapping subproblems | Memoisation or tabulation | Exponential becomes polynomial |
| Parsers and deserialisers | Built-in depth limits | Defends against hostile nesting |
| Hot path with measured call overhead | Iteration, confirmed with BenchmarkDotNet | Avoid guessing; measure |
Seniors also design the data to protect the code: database constraints or triggers that stop cycles in parent-child tables, domain rules for maximum depth, and parser limits. They recognise recursion's relatives too: overlapping subproblems lead to dynamic programming, a queue instead of a stack gives breadth-first search, and graph data needs a visited set.
Dynamic programming (DP) solves a problem by combining answers to smaller subproblems, storing each answer so it is computed once. It applies when:
fib(3) repeatedly).Two styles: memoisation (recursion plus a cache, top-down) and tabulation (fill a table in order, bottom-up, no recursion). Minimum coins: dp[a] = 1 + min(dp[a - c]) over coins c, O(amount × coins) time.
DFS goes as deep as possible along one branch before backtracking. It uses recursion or an explicit stack, plus a visited set. O(V + E) time, O(V) space (the longest path).
Key uses:
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.
Last updated 23 Oct 2026.