Maximum Call Stack Size Exceeded When It Is Not Recursion

Sometimes it is infinite recursion. Often it is spreading a large array into a function call, and the fix is completely different.

Share
Maximum Call Stack Size Exceeded When It Is Not Recursion. Abstract javascript illustration in orange and dark grey on debugly.dev

The short answer

RangeError: Maximum call stack size exceeded

Two very different causes:

  1. Infinite or very deep recursion. The stack trace repeats the same few frames.
  2. Too many arguments in one call. Math.max(...bigArray) or arr.push(...bigArray). The stack trace has no repetition and points at a single line.

Look at the trace. If it repeats, find the base case. If it does not, look for a spread operator or apply with a large array.

Tested on Node 22.14 and Chrome 133.

The non-recursive cause

This surprises people, so it is worth putting first.

const values = new Array(200_000).fill(0).map((_, i) => i);
const max = Math.max(...values);      // RangeError

The spread does not iterate. It places every element as a separate argument on the stack. Two hundred thousand arguments exceeds the frame size limit, and you get a stack overflow from code containing no recursion at all.

Same problem in several disguises:

arr.push(...otherBigArray);
target.concat.apply(target, bigArray);
String.fromCharCode(...bigByteArray);
Function.prototype.apply(null, bigArray);

The limit is engine and platform dependent, typically somewhere in the tens of thousands to low hundreds of thousands. Which means this works in testing with a thousand items and fails in production with a hundred thousand, and the error message points at recursion when there is none.

The fixes:

// max without spread
const max = values.reduce((a, b) => (b > a ? b : a), -Infinity);

// push in chunks
for (let i = 0; i < src.length; i += 10_000) {
  target.push(...src.slice(i, i + 10_000));
}

// or just concat
const merged = target.concat(src);

// bytes to string
const decoder = new TextDecoder();
const str = decoder.decode(byteArray);

The general rule: never spread an array whose length you do not control. If it comes from a database, a file, or a user, chunk it or use a method that iterates.

The recursive causes

A missing or unreachable base case

function walk(node) {
  process(node);
  for (const child of node.children) walk(child);   // fine unless there is a cycle
}

Correct for a tree, infinite for a graph with a cycle. If your data can have cycles, you need a visited set:

function walk(node, seen = new Set()) {
  if (seen.has(node.id)) return;
  seen.add(node.id);
  process(node);
  for (const child of node.children) walk(child, seen);
}

Cycles show up in category trees where somebody made a category its own ancestor, in organisation charts, and in any user editable hierarchy. It is a data problem that presents as a code crash.

Mutual recursion through a getter or proxy

class User {
  get displayName() {
    return this.displayName || this.email;    // reads itself
  }
}

The getter reads the property it defines. Easy to write, easy to miss in review, and the trace repeats a single frame which makes it quick to spot once you look.

Same with a Proxy whose get handler accesses the property it is intercepting.

JSON.stringify on a circular structure

Not a stack overflow in modern engines, which throw TypeError: Converting circular structure to JSON instead. Older environments and some custom serialisers still blow the stack.

If you need to serialise possibly circular data:

JSON.stringify(obj, (function () {
  const seen = new WeakSet();
  return (k, v) => {
    if (typeof v === "object" && v !== null) {
      if (seen.has(v)) return "[Circular]";
      seen.add(v);
    }
    return v;
  };
})());

Event handler loops

input.addEventListener("change", () => {
  input.value = normalise(input.value);
  input.dispatchEvent(new Event("change"));   // triggers itself
});

Not classic recursion, and the effect is the same. Guard with a flag, or do not re-dispatch.

Reading the trace

The trace tells you which case you have, and it is usually truncated.

RangeError: Maximum call stack size exceeded
    at walk (/app/tree.js:14:5)
    at walk (/app/tree.js:16:22)
    at walk (/app/tree.js:16:22)
    at walk (/app/tree.js:16:22)
    ...

Repetition means recursion. The two distinct line numbers tell you the entry and the recursive call.

For a deeper view:

node --stack-trace-limit=100 app.js

Useful when the recursion is mutual across several functions, where a short trace does not show the full cycle.

If the trace has no repetition and points at a single line with a spread or apply, you have the argument count problem.

Raising the stack size, and why not to

node --stack-size=8000 app.js

This almost always makes things worse. It buys you a slightly deeper recursion before crashing, and if the real problem is unbounded recursion you still crash, just later and after more work. It can also cause a segfault rather than a clean RangeError, because you have told V8 it has more stack than the OS actually gave the thread.

The only case where I would use it is a known-bounded recursion that is genuinely deeper than the default, such as parsing a deeply nested but finite structure. Even then, converting to iteration is better.

Converting recursion to iteration

For a genuinely deep structure, an explicit stack removes the limit:

function walkIterative(root) {
  const stack = [root];
  const seen = new Set();

  while (stack.length) {
    const node = stack.pop();
    if (seen.has(node.id)) continue;
    seen.add(node.id);

    process(node);
    for (const child of node.children) stack.push(child);
  }
}

Heap allocated, so the limit is memory rather than stack depth. This handles millions of nodes.

Note that this is depth first with reversed sibling order. For breadth first use a queue and shift, though shift is O(n) on a large array so use a proper deque or an index pointer.

JavaScript has no tail call optimisation in practice. It is in the specification, and only Safari implemented it. So a tail recursive function still grows the stack in Node and Chrome, and rewriting recursion as a tail call does not help.

Prevention

Never spread unbounded arrays. A lint rule can catch ... inside a call where the argument is not a literal, though it is noisy. The habit matters more.

Add a depth parameter to recursive functions that walk external data:

function walk(node, depth = 0) {
  if (depth > 100) throw new Error(`max depth exceeded at ${node.id}`);
  ...
}

That converts a stack overflow into an error message naming the node, which is dramatically more useful than a repeated stack frame.

Use a visited set whenever the data could contain a cycle. Assume it can if users can edit it.

Test with realistic sizes. A recursion depth or argument count problem is invisible with a small fixture and certain with a production sized one. Same category as the N+1 query that only appears at scale.