RangeError: Maximum call stack size exceeded - 10 Causes and Fixes
RangeError: Maximum call stack size exceeded means your code called functions faster than it returned from them, until the JavaScript engine ran out of room to track them. Nine times out of ten it is a recursion that never reaches its base case — the classic javascript recursion error — but the tenth time it is a getter calling itself, a React effect re-triggering its own dependency, or a perfectly fine function fed 200,000 arguments at once.
The stack is a fixed-size region the engine uses to remember where to return to. Every active call takes a frame. When frames exceed the limit, V8 throws this RangeError instead of corrupting memory.
This page gives you the one-line fix first, then ten distinct causes with runnable reproductions, then a checklist for the case where your stack trace is just the same two frames repeated 10,000 times and tells you nothing.
| Error | RangeError: Maximum call stack size exceeded |
|---|---|
| Where it happens | JavaScript — browser or Node.js, any framework. Thrown synchronously at the call site that overflowed. Firefox prints "InternalError: too much recursion"; Safari prints "RangeError: Maximum call stack size exceeded." with a trailing period. |
| What it means | Too many function calls are open at once — almost always a recursion with no reachable base case, or a single call spread over too many arguments. |
The Fast Fix
Find the function that appears repeated in your stack trace and give it a base case that is actually reachable.
function walk(node) {
if (!node) return; // base case: the recursion can now end
walk(node.next);
}
If the repeated frame is apply or a spread into Math.max / push, you are not recursing at all — you are passing too many arguments. Use a reduce or chunk the array instead:
const max = big.reduce((a, b) => (b > a ? b : a), -Infinity);
If the stack trace is empty or shows only framework frames, go to the diagnostic checklist.
What Is Actually Causing It
Jump to your case
- 1. Recursion with no base case at all
- 2. Base case exists but the argument never moves toward it
- 3. Two functions call each other indirectly
- 4. A property getter or setter reads itself
- 5. Spreading or applying a huge array as arguments
- 6. Circular reference walked by a recursive traversal
- 7. Recursion is correct but the input is too deep
- 8. A method override calls itself instead of the original
- 9. React: setState inside render or an effect that depends on its own output
- 10. An event handler dispatches the event it listens for
1. Recursion with no base case at all
Reproduce it
function countdown(n) {
console.log(n);
countdown(n - 1);
}
countdown(3); // 3, 2, 1, 0, -1, -2 ... RangeError
Why it happens — Every call pushes a new frame and nothing ever returns, so the stack grows until the engine refuses. n happily goes negative — numbers in JavaScript have no lower bound that stops you.
The fix
function countdown(n) {
if (n <= 0) return; // added: the recursion now terminates
console.log(n);
countdown(n - 1);
}
countdown(3);
Confirm it worked — Call it with the largest input you expect and confirm it returns. Add a temporary console.log right before the return in the base case — if it never prints, your base case is unreachable.
2. Base case exists but the argument never moves toward it
Reproduce it
function sum(arr, i = 0) {
if (i >= arr.length) return 0;
return arr[i] + sum(arr, i); // i never advances
}
sum([1, 2, 3]); // RangeError
Why it happens — The guard is correct, but the recursive call passes the same i forever, so the condition i >= arr.length is never satisfied. This is the most common variant in real code because the base case looks right in review.
The fix
function sum(arr, i = 0) {
if (i >= arr.length) return 0;
return arr[i] + sum(arr, i + 1); // i + 1: progress toward the base case
}
sum([1, 2, 3]); // 6
Confirm it worked — Log the changing argument on entry: console.log('sum', i). The values must strictly approach the base case. If you see the same value twice in a row, that is your bug.
3. Two functions call each other indirectly
Reproduce it
function isEven(n) { return isOdd(n - 1); }
function isOdd(n) { return isEven(n - 1); }
isEven(10); // RangeError — neither one stops
Why it happens — Mutual recursion still consumes stack frames, but neither function has a terminating branch, so each delegates to the other forever. Your stack trace alternates two frames, which makes it easy to miss that the cycle is the bug.
The fix
function isEven(n) { return n === 0 ? true : isOdd(n - 1); }
function isOdd(n) { return n === 0 ? false : isEven(n - 1); }
isEven(10); // true
Confirm it worked — Read the top 10 lines of the stack trace. If two or more distinct function names repeat in a cycle, each participant needs its own terminating branch — confirm by calling with the boundary value (0 here).
4. A property getter or setter reads itself
Reproduce it
const user = {
get name() {
return this.name; // reads the getter it is defined by
}
};
user.name; // RangeError
Why it happens — this.name triggers the same accessor, which triggers it again. There is no explicit recursive call in the source, so the trace shows only get name repeated and the cause is invisible unless you know this pattern.
The fix
const user = {
_name: 'ada',
get name() {
return this._name; // read the backing field, not the accessor
}
};
user.name; // 'ada'
Confirm it worked — Check that the identifier inside the accessor differs from the accessor's own name. In a debugger, break inside the getter and confirm the frame below it is the caller, not the getter again.
5. Spreading or applying a huge array as arguments
Reproduce it
const big = new Array(200000).fill(1);
Math.max(...big); // RangeError: Maximum call stack size exceeded
Why it happens — Spread and Function.prototype.apply place every element on the stack as a separate argument for a single call. There is no recursion at all — one call with enough arguments overflows by itself. The limit is engine-dependent, typically in the low hundreds of thousands.
The fix
const big = new Array(200000).fill(1);
const max = big.reduce((a, b) => (b > a ? b : a), -Infinity); // one frame, any length
Confirm it worked — Shrink the array to 100 elements. If the error disappears with no logic change, argument count was the cause, not recursion. For arr.push(...items), loop in chunks of a few thousand instead.
6. Circular reference walked by a recursive traversal
Reproduce it
const a = { name: 'a' };
const b = { name: 'b', peer: a };
a.peer = b;
function collect(node, out = []) {
out.push(node.name);
if (node.peer) collect(node.peer, out); // a -> b -> a -> b ...
return out;
}
collect(a); // RangeError
Why it happens — The recursion is correct for a tree but the data is a graph. Each node leads back to one already visited, so the depth is unbounded even though the object count is two.
The fix
function collect(node, out = [], seen = new WeakSet()) {
if (seen.has(node)) return out; // added: stop on a node already visited
seen.add(node);
out.push(node.name);
if (node.peer) collect(node.peer, out, seen);
return out;
}
collect(a); // ['a', 'b']
Confirm it worked — Run JSON.stringify(root) on the same data. If it throws TypeError: Converting circular structure to JSON, your structure has a cycle and the recursion needs a visited set.
More causes (4 remaining)
7. Recursion is correct but the input is too deep
Reproduce it
// Build a 100k-long linked list, then walk it recursively.
let head = null;
for (let i = 0; i < 100000; i++) head = { value: i, next: head };
function length(node) {
return node === null ? 0 : 1 + length(node.next);
}
length(head); // RangeError on large inputs
Why it happens — The base case is reachable and the argument advances — the algorithm is right. The depth simply exceeds the stack. JavaScript engines do not reliably eliminate tail calls, so depth-proportional recursion is depth-proportional stack usage.
The fix
function length(node) {
let n = 0;
while (node !== null) { n++; node = node.next; } // iteration: constant stack
return n;
}
length(head); // 100000
Confirm it worked — Halve the input size. If it succeeds at 50k and fails at 100k, depth is the problem, not logic — convert to a loop or an explicit stack array.
8. A method override calls itself instead of the original
Reproduce it
const originalLog = console.log;
console.log = function (...args) {
console.log('[app]', ...args); // calls the replacement, not the original
};
console.log('hi'); // RangeError
Why it happens — The wrapper references the property it just overwrote, so every call re-enters the wrapper. The same shape appears with super-less class overrides and monkey-patched fetch or addEventListener.
The fix
const originalLog = console.log;
console.log = function (...args) {
originalLog.call(console, '[app]', ...args); // call the captured original
};
console.log('hi'); // [app] hi
Confirm it worked — Confirm the wrapper body calls the saved reference, never the patched property name. Trigger it once and check the output appears exactly once.
9. React: setState inside render or an effect that depends on its own output
Reproduce it
function Counter() {
const [items, setItems] = useState([]);
useEffect(() => {
setItems([...items, 1]); // new array identity -> effect runs again
}, [items]);
return <div>{items.length}</div>;
}
Why it happens — The effect writes state that is in its own dependency array, and the new array has a different identity every time, so React re-runs the effect immediately. The synchronous render/update cycle nests deep enough to overflow the stack.
The fix
function Counter() {
const [items, setItems] = useState([]);
useEffect(() => {
setItems(prev => [...prev, 1]); // functional update, no items dependency
}, []); // runs once
return <div>{items.length}</div>;
}
Confirm it worked — Add console.count('effect') as the first line of the effect. It must settle at a small number. With React StrictMode in development, expect exactly two runs, not a climbing count.
10. An event handler dispatches the event it listens for
Reproduce it
const input = document.querySelector('input');
input.addEventListener('change', () => {
input.dispatchEvent(new Event('change')); // re-enters this handler synchronously
});
Why it happens — dispatchEvent runs listeners synchronously, so the handler calls itself through the event system. Same mechanism with a focus handler that calls .focus(), or a resize handler that changes layout size.
The fix
const input = document.querySelector('input');
let dispatching = false;
input.addEventListener('change', () => {
if (dispatching) return; // reentrancy guard
dispatching = true;
try {
input.dispatchEvent(new Event('change'));
} finally {
dispatching = false;
}
});
Confirm it worked — Look for the DOM API name (dispatchEvent, focus, click) repeated in the stack trace. After the guard, interact once and confirm the handler body logs once.
## None of Those? Narrow It Down
- Read the stack trace from the top and find the shortest repeating cycle of frame names. One name repeated means direct recursion; two or more alternating means mutual recursion;
get Xorset Xrepeated means an accessor reading itself. - If the repeated frame is
apply,Function.prototype.apply, or a spread into a built-in likeMath.maxorArray.prototype.push, stop looking for recursion — you are passing too many arguments in a single call. Shrink the array to confirm. - Halve the input size and re-run. If it now succeeds, your logic is correct and the depth is the problem: convert the recursion to a loop or an explicit stack. If it fails identically on a two-element input, the base case is unreachable.
- Log the recursive function's changing argument on entry. Identical consecutive values prove the argument is not advancing; values cycling through a small set prove your data has a cycle.
- Run
JSON.stringify(input)on the data you are traversing. ATypeError: Converting circular structure to JSONconfirms a cycle and tells you aWeakSetof visited nodes is the fix. - If the trace shows only framework frames (React reconciler, Vue scheduler, Express layers), look for state written during render or inside an effect that depends on its own output. Add
console.countat the entry of each suspect effect or handler. - If the trace is empty or truncated, re-run in Node with
node --stack-trace-limit=200 script.jsto see more frames, or setError.stackTraceLimit = 200at the top of the file in a browser. - If nothing above matches, binary-search the code path: comment out half the work inside the suspect function and re-run. The half that still throws contains the call that re-enters.
Why This Error Exists At All
Every function call needs somewhere to store its return address, its arguments, and its local variables. JavaScript engines keep that in a call stack: a contiguous, fixed-size block of memory allocated when the thread starts. Fixed size is the whole point — pushing and popping a frame is a pointer bump, which is why calling a function is cheap. A growable stack would make every call pay for a bounds check and a possible reallocation.
The consequence is a hard ceiling. When the next frame would run past the end of that block, the engine has two options: write past it and corrupt whatever sits next in memory, or refuse. V8 refuses, and the refusal surfaces as a RangeError — the same error class used for an out-of-range array length or an invalid toFixed digit count, because the engine really is reporting "this value is outside the range I can accept." The size is an implementation detail, not a spec guarantee, which is why your recursion survives 10,000 frames in Chrome and dies at a different depth in Safari or inside a Web Worker.
This also explains why the error has nothing specifically to do with recursion. Recursion is just the easiest way to open thousands of calls without returning from any of them — but spreading 200,000 arguments into one call consumes the same space in one shot, and so does a deeply nested synchronous event dispatch. Once you see the error as "too many open frames" rather than "bad recursion," the whole family becomes recognisable: deep recursion, mutual recursion, self-referencing accessors, reentrant event handlers, and argument-count overflow are five faces of one resource limit. And it explains the escape hatch: anything that lets a frame return before the next one opens — a while loop, an explicit array used as a worklist, or deferring to a microtask with queueMicrotask — makes the depth irrelevant, because the stack never holds more than one frame at a time.
Stop It From Coming Back
- Write the base case first, before the recursive call, and make the terminating condition the first statement in the function body. A guard clause at the top is hard to render unreachable later.
- Enable ESLint's
no-unsafe-negationandno-self-assign, and addeslint-plugin-react-hookswithreact-hooks/exhaustive-depsset toerror— it catches the effect-depends-on-its-own-state pattern before it ships. - For any traversal of user-supplied or API-supplied object graphs, thread a
WeakSetof visited nodes through the recursion as a standard parameter. Treat tree-shaped assumptions about external data as unverified. - Add a depth parameter with an explicit cap (
if (depth > 1000) throw new Error('max depth')) to recursive parsers and serializers. A domain error at depth 1000 is debuggable; aRangeErrorat depth 11,000 is not. - Unit-test recursive functions with a deliberately deep input — a 50,000-element linked list or a 10,000-level nested object — so depth limits fail in CI rather than on a large production payload.
- Replace
fn(...array)andfn.apply(null, array)withreduce, a loop, or fixed-size chunking whenever the array length is not statically bounded. Code-review any spread whose source is a database or API response.
Related Guides
Errors You Will Probably Hit Next
TypeError: Converting circular structure to JSONInternalError: too much recursion (Firefox's wording for the same overflow)Error: Maximum update depth exceeded (React's guard before the stack itself overflows)
Read this error as "too many open frames," not "bad recursion" — that reframing covers the argument-spread and reentrant-handler cases that look nothing like recursion in the source. Find the shortest repeating cycle in the stack trace, then ask two questions: does a base case exist, and does every call move measurably closer to it. When the answer to both is yes, the algorithm is fine and the depth is the problem, so switch to iteration or an explicit worklist array.
댓글
댓글 쓰기