Fix RangeError: Maximum call stack size exceeded in Node.js

intermediate๐Ÿ’š Node.js2026-03-24| Node.js (all versions), Linux / macOS / Windows

Error Message

RangeError: Maximum call stack size exceeded
#nodejs#recursion#stack-overflow#callstack#debug

What happened

Your Node.js script crashed with something like this:

RangeError: Maximum call stack size exceeded
    at Object.<anonymous> (/app/utils.js:12:5)
    at Object.<anonymous> (/app/utils.js:12:5)
    at Object.<anonymous> (/app/utils.js:12:5)
    ...

Notice how the same line repeats hundreds of times. That's your clue โ€” a function is calling itself (or calling another function that calls it back) without ever stopping.

Understanding the call stack

Every function call pushes a frame onto V8's call stack. V8 caps that stack at roughly 10,000โ€“15,000 frames, depending on the platform and available memory. Go deeper than that limit and V8 throws RangeError: Maximum call stack size exceeded rather than letting memory spiral out of control.

Three things cause this most often:

  • A recursive function with a missing or broken base case
  • Two functions calling each other in a cycle (mutual recursion)
  • Calling JSON.stringify on an object that references itself

Debug process

Step 1 โ€” Read the stack trace

By default Node.js only shows 10 frames. Bump that up so you can see the repeating pattern:

node --stack-trace-limit=50 index.js

Same function calling itself? Infinite recursion. Alternating A โ†’ B โ†’ A โ†’ B? Mutual recursion. Either way, the trace tells you exactly which file and line to open first.

Step 2 โ€” Add a depth counter

Before touching any logic, drop a counter into the suspicious function:

function processNode(node, depth = 0) {
  if (depth > 100) {
    console.log('Depth exceeded, node:', JSON.stringify(node, null, 2));
    throw new Error('Recursion depth guard triggered');
  }
  // ... rest of function
  return processNode(node.child, depth + 1);
}

Run it. The logged node value will show you the exact data that triggers runaway recursion โ€” far faster than staring at the code cold.

Step 3 โ€” Check for circular references

Did the error originate inside JSON.stringify? Circular reference. Classic example:

const a = { name: 'a' };
const b = { name: 'b', ref: a };
a.ref = b; // circular!
JSON.stringify(a); // => RangeError: Maximum call stack size exceeded

Detect it before serializing:

function hasCircular(obj) {
  const seen = new WeakSet();
  function detect(o) {
    if (typeof o !== 'object' || o === null) return false;
    if (seen.has(o)) return true;
    seen.add(o);
    return Object.values(o).some(detect);
  }
  return detect(obj);
}

console.log(hasCircular(a)); // true

Solutions

Fix 1 โ€” Add or fix the base case

This is the most common root cause by far. Every recursive function needs a condition that stops it cold:

// BROKEN โ€” recurses forever
function factorial(n) {
  return n * factorial(n - 1);
}

// FIXED
function factorial(n) {
  if (n <= 1) return 1; // <-- base case
  return n * factorial(n - 1);
}

Simple, but easy to miss when the base case logic depends on data shape rather than a numeric counter.

Fix 2 โ€” Convert deep recursion to iteration

Trees and linked lists can be arbitrarily deep in production. Rewrite with a loop and an explicit stack โ€” it handles any depth without touching V8's limit:

// Recursive (blows up on trees deeper than ~10,000 nodes)
function sumTree(node) {
  if (!node) return 0;
  return node.value + sumTree(node.left) + sumTree(node.right);
}

// Iterative (safe for any depth)
function sumTree(root) {
  const stack = [root];
  let total = 0;
  while (stack.length) {
    const node = stack.pop();
    if (!node) continue;
    total += node.value;
    stack.push(node.left, node.right);
  }
  return total;
}

Same result, zero stack risk.

Fix 3 โ€” Use trampolining for tail recursion

Refactoring to a loop sometimes mangles the algorithm's readability. Trampolining keeps the recursive style but sidesteps stack growth entirely:

function trampoline(fn) {
  return function(...args) {
    let result = fn(...args);
    while (typeof result === 'function') {
      result = result();
    }
    return result;
  };
}

const factorial = trampoline(function fact(n, acc = 1) {
  if (n <= 1) return acc;
  return () => fact(n - 1, n * acc); // thunk instead of direct call
});

console.log(factorial(50000)); // works fine

The key is returning a function (thunk) instead of calling recursively. The trampoline runner unwinds it in a plain loop.

Fix 4 โ€” Break async recursion with setImmediate

Async functions look safe from stack overflows. They're not. Each await resumes synchronously on the same stack frame if the promise is already resolved. Fix: explicitly yield to the event loop:

async function processItems(items, index = 0) {
  if (index >= items.length) return;
  await processOne(items[index]);
  // yield to the event loop โ€” clears the stack AND prevents blocking
  await new Promise(resolve => setImmediate(resolve));
  return processItems(items, index + 1);
}

Fix 5 โ€” Serialize circular objects safely

Two options. Roll your own replacer, or reach for the flatted package:

// Option A: custom replacer (no dependencies)
function safeStringify(obj) {
  const seen = new WeakSet();
  return JSON.stringify(obj, (key, value) => {
    if (typeof value === 'object' && value !== null) {
      if (seen.has(value)) return '[Circular]';
      seen.add(value);
    }
    return value;
  });
}

// Option B: flatted (preserves circular structure on parse)
// npm install flatted
import { stringify, parse } from 'flatted';
const serialized = stringify(circularObj);

Use Option A when you just need safe logging. Use Option B when you need to round-trip the data (serialize โ†’ deserialize with structure intact).

Verification

Apply the fix, then confirm it actually holds:

  • Run the script โ€” no RangeError this time.
  • Test with edge cases: empty input, single-element input, a tree 100,000 nodes deep.
  • For iterative rewrites, cross-check output against the recursive version on small inputs first.
  • Remove the depth guard โ€” or keep it with a sensible limit and a clear error message if it fires in production.
# Quick smoke test
node -e "const { sumTree } = require('./tree'); console.log(sumTree(buildDeepTree(100000)))"

Lessons learned

  • Repeating frames in a stack trace mean one thing: recursion without a working base case. Read the trace before touching any code.
  • Never trust that user-supplied data is shallow. Trees, nested configs, JSON from an API โ€” any of them can be arbitrarily deep. Use iteration.
  • JSON.stringify crashing almost always means circular references. A WeakSet replacer costs 5 lines. Write it once, reuse it everywhere.
  • Async recursion isn't stack-safe by default. If you're looping over thousands of items recursively, add a setImmediate yield or rewrite as a plain for loop with await.

Related Error Notes