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.
The short answer
RangeError: Maximum call stack size exceeded
Two very different causes:
- Infinite or very deep recursion. The stack trace repeats the same few frames.
- Too many arguments in one call.
Math.max(...bigArray)orarr.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.