Deep recursion
With very deep recursion JavaScript overflows the call stack: the engine throws RangeError: Maximum call stack size exceeded. Deep recursion means the function calls itself many times before reaching the base case, and all of those unfinished calls occupy the stack at once.
Theory
TL;DR
- Every recursive call creates a new execution context (a stack frame) on the call stack.
- A frame stores local variables, arguments and the return address.
- The stack only "unwinds" once the base case has been reached.
- The stack size is limited: usually around 10 000 to 20 000 nested calls.
- Exceeding the limit gives
RangeError: Maximum call stack size exceeded. - Even a correct base case does not help if the depth is too large.
- The ways out: a loop, tail recursion (where it is optimized) or chunks scheduled with
setTimeout.
Quick example
function recurse(n) {
console.log(n);
recurse(n + 1); // no base case
}
recurse(1);Output:
RangeError: Maximum call stack size exceededWhat happens under the hood
Every time a function calls itself, JavaScript creates a new execution context (a stack frame) on the call stack:
factorial(5)
-> factorial(4)
-> factorial(3)
-> factorial(2)
-> factorial(1)Every call stores:
- local variables,
- arguments,
- the return address.
Once the base case is reached, the stack "unwinds" back: the deepest call returns a value, its frame is freed, and so on up to the top. But if there are too many calls, the stack overflows before the unwinding can even start.
The browser (or Node.js) allocates a limited stack size, usually around 10 000 to 20 000 nested calls. The exact number depends on the engine, the platform and even on how many arguments and local variables your function has: the heavier the frame, the fewer of them fit.
Even with a base case you can hit the limit
function countdown(n) {
if (n === 0) return;
countdown(n - 1);
}
countdown(100000); // RangeErrorDespite the base case being there, the recursion depth (100 000) is too large for the JavaScript stack. In other words, a base case protects you from infinite recursion but not from deep recursion. That is an important distinction in an interview: correct logic and a safe depth are two separate requirements.
How to avoid the problem
1. Rewrite the recursion as a loop
function countdown(n) {
while (n > 0) n--;
}A loop keeps the state in a single variable and adds no frames at all, so depth stops being a constraint.
2. Use tail recursion (if the optimization existed)
function countdown(n) {
if (n === 0) return;
return countdown(n - 1); // tail call
}A tail call is one where the recursive call is the last action of the function, so its frame is no longer needed and could in theory be reused. But most JavaScript engines do not implement tail call optimization, so you cannot rely on it: the code above still blows up.
3. Split the recursion into chunks with setTimeout
function countdown(n) {
if (n === 0) return;
console.log(n);
setTimeout(() => countdown(n - 1), 0); // does not grow the stack
}Here every call runs in a new event loop iteration: the previous call has already finished and its frame has been freed, so the stack does not grow. The price is that the function becomes asynchronous, so the result can only be returned through a callback or a Promise.
4. Move the state into your own stack array
function collectValues(root) {
const out = [];
const stack = [root];
while (stack.length > 0) {
const node = stack.pop();
if (!node) continue;
out.push(node.value);
for (const child of node.children ?? []) stack.push(child);
}
return out;
}The array lives on the heap rather than on the call stack, so the depth limit effectively disappears.
Summary table
| Phenomenon | What happens |
|---|---|
| Deep recursion | Many nested calls of the same function |
| Result | Call stack overflow (RangeError) |
| Why | Every call creates a new context on the stack |
| How to avoid it | Use a loop, tail recursion or setTimeout |
Common mistakes
- Confusing infinite recursion with deep recursion. A base case saves you from the first but not from the second:
countdown(100000)is logically correct and still crashes. - Counting on tail call optimization. It is in the specification, but mostly absent from real JavaScript engines, so rewriting code "in tail style" does not make it any safer.
- Treating the limit as a fixed number. It depends on the engine, the platform and the frame size, so a test that passes on one machine can fail on another.
- Catching
RangeErrorin atry/catchand calling it solved. Catching hides the bug, but the work is still not done and the state may be left half updated. - Forgetting that
setTimeoutmakes the function asynchronous. After that changereturnno longer hands a result to the caller; you need a callback or aPromise.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.