Why Recursion Causes Maximum Call Stack Size Exceeded
RangeError: Maximum call stack size exceeded is a memory error, but not the kind the rest of this site is about: it is the stack running out, not the heap. This page belongs to stack vs heap memory allocation in JavaScript within JavaScript Memory Fundamentals & Runtime Mechanics.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| Two or three frames repeating in the trace | Unbounded recursion, no base case | Add the missing termination condition |
| One frame repeated thousands of times | Legitimate depth exceeding the stack | Convert to iteration with an explicit array |
| Works on small inputs, fails on large ones | Depth scales with input size | Same conversion; depth then bounded by heap |
| Fails at different depths on different machines | Limit depends on frame size and engine | Never rely on a specific depth |
| Node segfaults instead of throwing | --stack-size raised above the OS thread limit |
Lower it below the OS limit, or convert |
Root Cause: A Fixed Region With Variable-Sized Frames
The call stack is a fixed-size region allocated when the thread starts — typically under a megabyte for the main thread. Every function call pushes a frame containing the return address, the arguments, the local variables and any spilled registers. Frames are popped on return. The region does not grow.
That is why there is no single depth limit. A frame’s size depends on how many parameters and locals the function has, so a two-argument function with no locals might manage 15 000 frames while a function holding a dozen locals exhausts the same region at 6 000. Engines differ, thread stacks differ between the main thread and workers, and the number moves between browser versions. Any code that depends on a specific depth is depending on an accident.
The distinction that matters diagnostically is between unbounded and deep. Unbounded recursion — a missing base case, or two functions calling each other — shows as a short cycle repeating in the trace, and the fix is the termination condition. Genuine depth — walking a linked list of 50 000 nodes, or a deeply nested document — shows as one frame repeated, and no stack size will ever be enough because the depth is a function of the input.
The conversion for the second case is always the same shape: move the pending work from the stack into an array on the heap. The algorithm is unchanged; the state simply lives somewhere that can grow. As covered in the parent guide, this does not reduce memory use — it relocates it to a region measured in hundreds of megabytes rather than hundreds of kilobytes.
Step-by-Step Fix
- Read the trace shape. Expected: a repeating cycle of two or three frames means unbounded; one frame repeated means depth.
- Measure the real limit for that function. Command: the probe below. Expected: a number specific to this engine and frame size — useful for understanding, never for depending on.
- For unbounded recursion, add the base case. Verification: the probe now terminates on its own.
- For genuine depth, convert to iteration. Verification: the same input that threw now completes, and depth scales with heap rather than stack.
- Test with an input several times deeper. Verification: an input ten times past the old failure point completes, which proves the limit is gone rather than moved.
Command & Code Reference
Measuring the achievable depth, which differs per function and per engine.
// The limit is a function of FRAME SIZE, so measure the function you care
// about rather than an empty one.
function maxDepth(fn) {
let depth = 0;
function probe() {
depth++;
return probe(); // no base case — deliberately overflows
}
try { probe(); } catch (e) {
if (!(e instanceof RangeError)) throw e;
}
return depth;
}
console.log('empty frames:', maxDepth()); // e.g. ~11 000 in Chrome
The standard conversion: an explicit stack on the heap, with the same traversal order.
// Recursive: one frame per node, overflows on deep trees.
function sumRecursive(node) {
if (!node) return 0;
return node.value + sumRecursive(node.left) + sumRecursive(node.right);
}
// Iterative: two frames total, pending nodes held in an array that can grow
// into the hundreds of megabytes the heap has available.
function sumIterative(root) {
let total = 0;
const pending = [root];
while (pending.length) {
const node = pending.pop();
if (!node) continue;
total += node.value;
pending.push(node.left, node.right); // heap-allocated, not stack frames
}
return total;
}
Chunking work that must stay recursive, so the stack unwinds between batches.
// When a rewrite is too invasive, break the recursion with a scheduler:
// each batch runs to a bounded depth, then yields, and the stack unwinds.
async function processDeep(node, batchSize = 500) {
let pending = [node];
while (pending.length) {
const batch = pending.splice(0, batchSize);
for (const n of batch) {
handle(n);
if (n.children) pending.push(...n.children);
}
// Yielding returns to the event loop with an empty stack, which also
// keeps the page responsive during a long traversal.
await new Promise((r) => setTimeout(r, 0));
}
}
Verification & Regression Prevention
Verify with an input well past the previous failure point: if the old code broke at 11 000 nodes, test the new code at 100 000. A conversion that merely enlarged the frames — by moving locals around without removing the recursion — will fail again a little later, and only a much larger input reveals it.
For prevention, add a depth guard to any recursive function that walks user-supplied structure. A guard that throws a domain error at, say, 1 000 levels gives a message someone can act on, instead of a RangeError whose stack trace is 11 000 identical lines. And where recursion is genuinely the clearest expression of the algorithm, keep it and bound the input instead — validating that an uploaded document is not nested 40 000 levels deep is usually the more honest fix.
FAQ
How deep can JavaScript recursion actually go?
It depends on frame size rather than a fixed count. A function with two parameters and no locals may reach 15 000 frames while one with a dozen locals reaches 6 000, in the same engine on the same machine. Node raises it with --stack-size, and browsers do not expose a setting at all, so treat any specific number as unportable.
Does tail-call optimisation help?
Not in practice. Proper tail calls are specified but shipped only in JavaScriptCore, so code relying on them breaks in V8 and SpiderMonkey. Convert to iteration instead; it is portable and usually clearer than restructuring a function to be tail-recursive.
Is raising --stack-size a reasonable fix in Node?
Only as a temporary measure, and carefully: the flag must stay below the operating system’s thread stack limit or the process segfaults instead of throwing a catchable error. A crash with no stack trace is strictly worse than the RangeError it replaced.
Related
- Stack vs Heap Memory Allocation in JavaScript — where each kind of value lives and why
- Primitives vs Objects: Where JavaScript Values Live — what a frame actually holds
- Memory Limits and Out-of-Heap Errors in Node.js — the other memory error, and why it is unrelated