Preventing Stack Overflow in JavaScript Recursion

Learn how to prevent stack overflow errors in JavaScript recursive functions. Covers call stack limits, tail call optimization, trampolines, iterative conversion, and memoization techniques for safe deep recursion.

JavaScriptbeginner
10 min read

Every recursive function call adds a frame to JavaScript's call stack. When the recursion goes too deep, the stack runs out of space and throws RangeError: Maximum call stack size exceeded. This tutorial covers why stack overflows happen, how to detect them, and five practical techniques to prevent them: base case fixes, iterative conversion, trampolines, memoization, and chunked processing.

Why Stack Overflows Happen

JavaScript engines allocate a fixed amount of memory for the call stack. Each function call pushes a new frame containing the function's local variables, arguments, and return address. In normal code this is fine because functions return quickly and their frames are popped. But recursion stacks frames deep:

javascriptjavascript
function recurse(n) {
  if (n === 0) return "done";
  return recurse(n - 1);
}
 
recurse(100);     // works fine
recurse(10000);   // works fine (usually)
recurse(100000);  // RangeError: Maximum call stack size exceeded

Stack Size Limits by Engine

JavaScript engineBrowser/runtimeApproximate limit
V8Chrome, Node.js, Edge~10,000-15,000 frames
SpiderMonkeyFirefox~10,000-50,000 frames
JavaScriptCoreSafari~36,000-65,000 frames

These limits vary based on frame size (more local variables = fewer frames), available memory, and engine version.

Measuring Your Stack Limit

javascriptjavascript
function measureStackDepth(depth = 0) {
  try {
    return measureStackDepth(depth + 1);
  } catch (e) {
    return depth;
  }
}
 
console.log(`Max stack depth: ${measureStackDepth()}`);
// Output varies: typically 10,000-15,000 in V8

Technique 1: Fix the Base Case

The most common cause of stack overflow is a missing or broken base case:

javascriptjavascript
// BUG: base case never reached for negative numbers
function factorial(n) {
  if (n === 0) return 1;        // only catches exactly 0
  return n * factorial(n - 1);  // factorial(-1) overflows
}
 
// FIX: handle edge cases
function factorial(n) {
  if (n <= 1) return 1;         // catches 0, 1, and negatives
  return n * factorial(n - 1);
}
 
// BUG: floating point never reaches base case
function halve(n) {
  if (n === 0) return 0;       // 0.5 / 2 = 0.25 / 2 = 0.125... never exactly 0
  return halve(n / 2);
}
 
// FIX: use a threshold
function halve(n) {
  if (n < 0.001) return 0;     // close enough to zero
  return halve(n / 2);
}
Always Validate Inputs

Add input validation to recursive functions. Check for negative numbers, non-integers, empty inputs, and NaN before starting recursion. A single unexpected value can cause infinite recursion.

Technique 2: Convert to Iteration

Any recursive function can be rewritten as a loop. Loops do not add stack frames, so they handle arbitrarily deep data:

Simple Recursion to Loop

javascriptjavascript
// Recursive
function sumTo(n) {
  if (n <= 0) return 0;
  return n + sumTo(n - 1);
}
 
// Iterative: no stack growth
function sumTo(n) {
  let total = 0;
  for (let i = 1; i <= n; i++) {
    total += i;
  }
  return total;
}
 
console.log(sumTo(1000000)); // 500000500000 (works instantly)

Tree Traversal with Explicit Stack

When recursion navigates tree structures, replace the call stack with your own stack (an array):

javascriptjavascript
// Recursive tree traversal
function findFilesRecursive(node) {
  if (node.type === "file") return [node.name];
  return node.children.flatMap((child) => findFilesRecursive(child));
}
 
// Iterative with explicit stack: no stack overflow risk
function findFilesIterative(root) {
  const result = [];
  const stack = [root];
 
  while (stack.length > 0) {
    const node = stack.pop();
 
    if (node.type === "file") {
      result.push(node.name);
    } else if (node.children) {
      // Push children onto the stack (reverse for left-to-right order)
      for (let i = node.children.length - 1; i >= 0; i--) {
        stack.push(node.children[i]);
      }
    }
  }
 
  return result;
}

Flatten Nested Arrays Iteratively

javascriptjavascript
// Recursive: can overflow on deeply nested arrays
function flattenRecursive(arr) {
  return arr.reduce((flat, item) =>
    flat.concat(Array.isArray(item) ? flattenRecursive(item) : item), []);
}
 
// Iterative: handles any depth
function flattenIterative(arr) {
  const stack = [...arr];
  const result = [];
 
  while (stack.length > 0) {
    const item = stack.pop();
    if (Array.isArray(item)) {
      stack.push(...item);
    } else {
      result.unshift(item);
    }
  }
 
  return result;
}
 
// Or use the built-in
const flat = [1, [2, [3, [4, [5]]]]].flat(Infinity);

Technique 3: Trampoline Pattern

A trampoline converts recursion into iteration by returning a function instead of calling it. The trampoline then repeatedly calls the returned function in a loop:

javascriptjavascript
// The trampoline utility
function trampoline(fn) {
  return function (...args) {
    let result = fn(...args);
    while (typeof result === "function") {
      result = result(); // call the thunk
    }
    return result;
  };
}
 
// Convert recursive function: return a function instead of calling directly
function sumToRecursive(n, total = 0) {
  if (n <= 0) return total;
  return () => sumToRecursive(n - 1, total + n); // return thunk, do not call
}
 
const sumTo = trampoline(sumToRecursive);
 
console.log(sumTo(1000000)); // 500000500000 (no stack overflow!)

How It Works

CodeCode
sumTo(5)
  -> sumToRecursive(5, 0) returns () => sumToRecursive(4, 5)
  -> trampoline calls the thunk: sumToRecursive(4, 5)
  -> returns () => sumToRecursive(3, 9)
  -> trampoline calls the thunk: sumToRecursive(3, 9)
  -> ... and so on until the base case returns a value (not a function)

The key insight: only one stack frame is active at a time because each call returns immediately. The trampoline loop drives the computation without growing the stack.

Trampoline for Factorial

javascriptjavascript
function factorialTrampoline(n, accumulator = 1) {
  if (n <= 1) return accumulator;
  return () => factorialTrampoline(n - 1, n * accumulator);
}
 
const factorial = trampoline(factorialTrampoline);
 
console.log(factorial(5));     // 120
console.log(factorial(20));    // 2432902008176640000
console.log(factorial(100));   // Infinity (number too large, but no stack overflow)

Mutual Recursion with Trampoline

javascriptjavascript
function isEvenTramp(n) {
  if (n === 0) return true;
  return () => isOddTramp(n - 1);
}
 
function isOddTramp(n) {
  if (n === 0) return false;
  return () => isEvenTramp(n - 1);
}
 
function trampolineMutual(fn, ...args) {
  let result = fn(...args);
  while (typeof result === "function") {
    result = result();
  }
  return result;
}
 
console.log(trampolineMutual(isEvenTramp, 1000000)); // true (no overflow)
Trampoline Trade-Off

Trampolines eliminate stack overflow risk but add overhead from creating function objects on every iteration. For performance-critical code, prefer direct iterative conversion. Use trampolines when the recursive logic is complex and hard to convert to a loop.

Technique 4: Memoization

Memoization does not increase stack depth, but it dramatically reduces the total number of recursive calls by caching results. This prevents the exponential call explosion that causes many practical overflow issues:

javascriptjavascript
// Without memoization: fibonacci(40) makes ~1 billion calls
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}
 
// With memoization: fibonacci(40) makes ~80 calls
function fibonacciMemo(n, cache = new Map()) {
  if (cache.has(n)) return cache.get(n);
  if (n <= 1) return n;
 
  const result = fibonacciMemo(n - 1, cache) + fibonacciMemo(n - 2, cache);
  cache.set(n, result);
  return result;
}
 
console.log(fibonacciMemo(40));  // 102334155 (instant)
console.log(fibonacciMemo(100)); // 354224848179262000000

When Memoization Helps vs Does Not Help

ScenarioHelps?Why
Fibonacci, grid pathsYesMany overlapping sub-problems
FactorialNoEach sub-problem is called only once
Tree traversalNoEach node is visited only once
Parsing nested JSONMaybeDepends on repeated patterns

Technique 5: Chunked/Batched Processing

For processing large arrays or datasets recursively, break the work into chunks and use setTimeout or requestAnimationFrame to avoid blocking the stack:

javascriptjavascript
function processLargeArray(items, batchSize = 1000, callback) {
  let index = 0;
 
  function processBatch() {
    const end = Math.min(index + batchSize, items.length);
 
    for (let i = index; i < end; i++) {
      callback(items[i], i);
    }
 
    index = end;
 
    if (index < items.length) {
      setTimeout(processBatch, 0); // yield to event loop
    }
  }
 
  processBatch();
}
 
// Process 1 million items without blocking or overflowing
const bigArray = Array.from({ length: 1000000 }, (_, i) => i);
processLargeArray(bigArray, 5000, (item) => {
  // process each item
});

Async Generator Pattern

javascriptjavascript
async function* processInChunks(items, chunkSize = 1000) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    yield chunk;
    // Optional: small delay to keep UI responsive
    await new Promise((resolve) => setTimeout(resolve, 0));
  }
}
 
// Usage
async function processAll(items) {
  for await (const chunk of processInChunks(items)) {
    chunk.forEach((item) => {
      // process item
    });
  }
}

Tail Call Optimization (TCO)

The ES6 specification includes Tail Call Optimization, which reuses the current stack frame when a function's last action is a recursive call. In theory, this makes tail-recursive functions run in constant stack space:

javascriptjavascript
// Tail-recursive: recursive call is the last operation
function factorial(n, acc = 1) {
  if (n <= 1) return acc;
  return factorial(n - 1, n * acc); // tail position
}
 
// NOT tail-recursive: multiplication happens after the call
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1); // n * ... is the last operation
}

TCO Support Reality

EngineSupports TCO?Notes
JavaScriptCore (Safari)YesOnly browser with TCO
V8 (Chrome, Node.js)NoRemoved in 2016 (performance concerns)
SpiderMonkey (Firefox)NoNever implemented

Because only Safari supports TCO, you cannot rely on it in production. Use trampolines or iterative conversion instead.

Decision Flowchart

CodeCode
Is maximum depth known and small (< 5000)?
โ”œโ”€โ”€ Yes โ†’ Use normal recursion
โ””โ”€โ”€ No โ†’ Can you convert to iteration easily?
    โ”œโ”€โ”€ Yes โ†’ Convert to a loop (with explicit stack if needed)
    โ””โ”€โ”€ No โ†’ Does the problem have overlapping sub-problems?
        โ”œโ”€โ”€ Yes โ†’ Add memoization (may also need trampoline)
        โ””โ”€โ”€ No โ†’ Use trampoline pattern

Defensive Recursion Pattern

Add a depth guard to catch runaway recursion early with a helpful error message:

javascriptjavascript
function safeRecurse(data, maxDepth = 1000, depth = 0) {
  if (depth > maxDepth) {
    throw new Error(`Maximum recursion depth exceeded (${maxDepth})`);
  }
 
  if (!data.children) return [data.value];
 
  return data.children.flatMap((child) =>
    safeRecurse(child, maxDepth, depth + 1)
  );
}
 
// Catches circular references or unexpectedly deep trees
try {
  safeRecurse(myDeepTree);
} catch (e) {
  console.error(e.message);
}

Detecting Circular References

Circular references cause infinite recursion. Track visited nodes with a Set:

javascriptjavascript
function safeTraverse(obj, visited = new WeakSet()) {
  if (obj === null || typeof obj !== "object") return obj;
 
  // Circular reference detected
  if (visited.has(obj)) return "[Circular Reference]";
  visited.add(obj);
 
  if (Array.isArray(obj)) {
    return obj.map((item) => safeTraverse(item, visited));
  }
 
  const result = {};
  for (const [key, value] of Object.entries(obj)) {
    result[key] = safeTraverse(value, visited);
  }
  return result;
}
 
// Test with circular reference
const a = { name: "a" };
const b = { name: "b", ref: a };
a.ref = b; // circular!
 
console.log(safeTraverse(a));
// { name: "a", ref: { name: "b", ref: "[Circular Reference]" } }

Putting it all together - a function that searches nested objects safely using depth guards, circular reference detection, and iterative fallback:

javascriptjavascript
function findAllPaths(obj, targetValue) {
  const results = [];
  const stack = [{ node: obj, path: "" }];
  const visited = new WeakSet();
 
  while (stack.length > 0) {
    const { node, path } = stack.pop();
 
    if (node === targetValue) {
      results.push(path || "(root)");
      continue;
    }
 
    if (node === null || typeof node !== "object") continue;
    if (visited.has(node)) continue;
    visited.add(node);
 
    for (const [key, value] of Object.entries(node)) {
      stack.push({
        node: value,
        path: path ? `${path}.${key}` : key,
      });
    }
  }
 
  return results;
}
 
const data = {
  users: [
    { name: "Alice", role: "admin" },
    { name: "Bob", role: "user" },
    { preferences: { defaultRole: "admin" } },
  ],
};
 
console.log(findAllPaths(data, "admin"));
// ["users.0.role", "users.2.preferences.defaultRole"]
Rune AI

Rune AI

Key Insights

  • Fix base cases first: most overflows come from missing or unreachable base cases
  • Convert to iteration: replace recursion with loops and an explicit stack array
  • Trampoline pattern: return thunks instead of making direct recursive calls
  • Memoize: cache results to eliminate redundant call branches
  • Depth guards: add a max depth parameter and throw clearly when exceeded
  • Detect circular refs: use WeakSet to track visited objects
RunePowered by Rune AI

Frequently Asked Questions

How do I know if my recursion will overflow?

Estimate the maximum depth. If your data is a balanced binary tree with n items, depth is log2(n) - usually safe. If your data is a linked list or deeply nested array, depth equals n - risky for n > 10,000. When in doubt, add a depth parameter and throw if it exceeds a limit.

Is the trampoline pattern widely used?

It is well-known in functional programming communities but less common in everyday JavaScript. Libraries like Ramda and fp-ts use it internally. For most web development, converting to iteration is simpler and more idiomatic.

Can I increase the stack size?

In Node.js, you can use `--stack-size=8192` to increase the limit. In browsers, you cannot control stack size. Do not rely on increasing stack size - fix the recursion instead.

What about Web Workers for heavy recursion?

Web Workers run in a separate thread with their own call stack, but the stack size limit is still the same. They help with keeping the UI responsive during heavy computation, not with preventing stack overflow.

Conclusion

Stack overflow in JavaScript recursion happens when the call stack exceeds its limit. Prevent it by ensuring correct base cases, converting to iterative loops with explicit stacks, using the trampoline pattern for complex recursive logic, applying memoization for overlapping sub-problems, and adding depth guards and circular reference detection for safety. While tail call optimization exists in the ES6 spec, only Safari supports it, so use alternative strategies in production code.