Skip to main content

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

javascript
function recurse(n) { console.log(n); recurse(n + 1); // no base case } recurse(1);

Output:

javascript
RangeError: Maximum call stack size exceeded

What happens under the hood

Every time a function calls itself, JavaScript creates a new execution context (a stack frame) on the call stack:

javascript
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

javascript
function countdown(n) { if (n === 0) return; countdown(n - 1); } countdown(100000); // RangeError

Despite 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

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

javascript
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

javascript
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

javascript
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

PhenomenonWhat happens
Deep recursionMany nested calls of the same function
ResultCall stack overflow (RangeError)
WhyEvery call creates a new context on the stack
How to avoid itUse 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 RangeError in a try/catch and calling it solved. Catching hides the bug, but the work is still not done and the state may be left half updated.
  • Forgetting that setTimeout makes the function asynchronous. After that change return no longer hands a result to the caller; you need a callback or a Promise.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.