Fix: RangeError: Maximum call stack size exceeded (JavaScript)
Part of: React & Frontend Errors
Quick Answer
Fix JavaScript 'RangeError: Maximum call stack size exceeded': find the runaway recursion, add a base case, fix accidental self-reference, and convert deep recursion to iteration.
I once spent an afternoon staring at a stack trace that scrolled for a full minute before I spotted the function calling itself. In my experience Maximum call stack size exceeded means one thing almost every time: a function recurses without ever stopping. The trace looks intimidating because it repeats the same two or three frames thousands of times, but that repetition is a gift, it points straight at the loop. The first time I hit this, the cause was a setter that assigned to its own property, exactly the kind of line you read past ten times before it finally clicks. This guide walks the causes from the obvious infinite recursion to the subtle ones, and shows how to fix code that recurses too deep even when it is not a bug.
RangeError: Maximum call stack size exceeded
The exact message depends on the JavaScript engine. In Chrome, Node.js, and Edge (all V8), and in Safari (JavaScriptCore):
RangeError: Maximum call stack size exceeded
at factorial (app.js:2:20)
at factorial (app.js:4:12)
at factorial (app.js:4:12)
at factorial (app.js:4:12)
... (thousands more identical frames)Firefox (SpiderMonkey) words it differently for the same underlying problem:
InternalError: too much recursionThe repeated frame is the whole story. Whatever function name fills those thousands of identical lines is the one recursing without an exit.
What overflows the call stack
Every time you call a function, the engine pushes a frame onto the call stack: the return address, arguments, and local variables. The stack is a fixed-size region of memory. When calls pile up faster than they return, the stack fills and the engine aborts with a RangeError rather than corrupting memory. This is different from running out of heap, which shows up as JavaScript heap out of memory. The stack holds pending function calls; the heap holds objects.
The limit is not huge. V8 allows on the order of ten thousand simple frames before it stops, and the exact number shrinks as each frame grows (more arguments and locals mean fewer frames fit). So this error shows up in three distinct situations:
- Unbounded recursion (a bug). A function calls itself with no base case, or a base case it never reaches. This overflows almost instantly.
- Deep but finite recursion (a design limit). A correct recursive function runs against genuinely deep data: a linked list of 100,000 nodes, a deeply nested JSON tree, a large array processed recursively. The logic is fine, but the depth exceeds what the stack can hold.
- Too many arguments at once (not recursion at all). Spreading a very large array into a call, like
Math.max(...bigArray), pushes every element onto the stack as a separate argument. Past a limit the engine throws the sameRangeError, with no recursive function anywhere in the trace.
The fix is different for each. Unbounded recursion needs a corrected base case. Deep finite recursion needs iteration or chunking. The argument-count case needs a different call shape, covered further down. Telling them apart is the first job, and the stack trace usually settles it.
Find the runaway function fast
Read the stack trace, not the code. The function name that repeats thousands of times is the culprit. In Node, the default trace is truncated to 10 frames, which hides the pattern. Show more of it:
Error.stackTraceLimit = Infinity; // put this at the top of your entry filenode --stack-trace-limit=1000 app.jsIn Chrome or Edge DevTools, open Sources, click the pause icon to pause on exceptions, and rerun. Execution stops at the overflow with the full call stack visible on the right. A wall of the same frame confirms recursion and names the function.
When the recursion is indirect (A calls B calls A), the trace shows two or three names alternating instead of one. That alternation is the signature of mutual recursion.
To see how much headroom you actually have, measure it. The limit varies by engine and by how large each frame is, so the number is different in every codebase:
let depth = 0;
function probe() { depth++; probe(); }
try { probe(); } catch { console.log(`Overflowed at depth ${depth}`); }
// V8 typically lands somewhere around 10,000 to 15,000 for a trivial frameIn a production build the trace often shows single-letter minified names, a calling a calling a, which hides the real function. Turn on source maps so the frames map back to your original code. In Node, run with --enable-source-maps (or set NODE_OPTIONS=--enable-source-maps); browser DevTools apply source maps automatically when they are served alongside the bundle.
Add or fix the base case
The textbook cause is a recursive function whose base case is missing or never true:
// BUG: no base case, recurses forever
function countdown(n) {
console.log(n);
return countdown(n - 1);
}
countdown(10); // 10, 9, 8, ... -1, -2, ... RangeError// FIX: stop at zero
function countdown(n) {
if (n < 0) return; // base case
console.log(n);
return countdown(n - 1);
}The subtler version has a base case that the recursion steps past. If n can be a non-integer or start negative, n === 0 is never hit:
// BUG: base case n === 0 is skipped when n starts at 5.5 or below 0
function factorial(n) {
if (n === 0) return 1;
return n * factorial(n - 1);
}
factorial(5.5); // 5.5, 4.5, ... 0.5, -0.5, ... never equals 0
// FIX: use a boundary comparison, not equality
function factorial(n) {
if (n <= 0) return 1;
return n * factorial(n - 1);
}Guard against runaway recursion in development by passing a depth limit while you debug:
function walk(node, depth = 0) {
if (depth > 10000) throw new Error(`Recursion too deep at ${node.id}`);
for (const child of node.children) walk(child, depth + 1);
}Fix accidental recursion in setters, getters, and toString
Some of the nastiest cases are not recursive-looking at all. A property setter that assigns to its own property calls itself forever:
// BUG: the setter assigns to `name`, which calls the setter again
class User {
set name(value) {
this.name = value; // RangeError on the first assignment
}
}
new User().name = 'Ada';// FIX: store in a backing field
class User {
set name(value) {
this._name = value;
}
get name() {
return this._name;
}
}A getter that reads its own property loops the same way. So does a toString or valueOf that coerces the object it lives on:
// BUG: the template literal coerces `this` to a string, calling toString again
const money = {
amount: 5,
toString() {
return `$${this}`; // should be `${this.amount}`
}
};
`${money}`; // RangeErrorThe same trap appears with a proxy whose get trap reads the property it is trapping, or a Redux reducer that spreads the state into itself through a getter. When the error points at a class or object with no obvious recursive call, suspect an accessor.
Convert deep recursion to iteration
When the recursion is correct but the data is deep, no base-case fix helps. Rewrite the recursion as a loop with an explicit stack. This trades the call stack (fixed, small) for the heap (large), so it handles depths that would overflow.
A recursive tree walk:
// Overflows on a deeply nested tree
function walk(node, visit) {
visit(node);
for (const child of node.children) walk(child, visit);
}The iterative version with an explicit stack:
function walk(root, visit) {
const stack = [root];
while (stack.length) {
const node = stack.pop();
visit(node);
// push children in reverse to visit them left-to-right
for (let i = node.children.length - 1; i >= 0; i--) {
stack.push(node.children[i]);
}
}
}A recursive sum over a linked list of 200,000 nodes overflows; the loop does not:
function sumList(head) {
let total = 0;
for (let node = head; node !== null; node = node.next) {
total += node.value;
}
return total;
}When it is not recursion at all: Math.max(…bigArray)
Not every overflow involves a recursive function. Spreading a large array into a call passes each element as its own argument, and the engine can only push so many arguments before the stack is full:
const big = Array.from({ length: 200_000 }, () => Math.random());
Math.max(...big); // RangeError: Maximum call stack size exceeded
Math.min(...big); // same
arr.push(...big); // same
[].concat(...arrays); // same when the total gets large enough
fn.apply(null, big); // apply has the same limit as spreadThe threshold is not a clean number. It runs from tens of thousands to somewhere past a hundred thousand depending on the engine and version, which is why this ships fine and then breaks on a larger dataset in production. The trace is the tell: no repeating frame, just one RangeError at the call site.
Replace the spread with an operation that consumes the array without turning it into arguments:
// Reduce instead of spreading into Math.max
const max = big.reduce((m, n) => (n > m ? n : m), -Infinity);
// Loop or chunk instead of push(...)
for (const x of big) arr.push(x);
// flat() instead of concat(...)
const merged = arrays.flat();Trampoline a tail-recursive function
JavaScript engines do not save you here. ES2015 specified proper tail calls, but only Safari’s JavaScriptCore ever shipped them. V8 (Node, Chrome) and SpiderMonkey (Firefox) never did, so a tail-recursive function still grows the stack in Node. A trampoline sidesteps this by returning a function to call next instead of calling it directly, keeping the stack flat:
function trampoline(fn) {
return (...args) => {
let result = fn(...args);
while (typeof result === 'function') {
result = result();
}
return result;
};
}
// Return a thunk instead of recursing directly
function sumTo(n, acc = 0) {
if (n === 0) return acc;
return () => sumTo(n - 1, acc + n);
}
const sum = trampoline(sumTo);
sum(1_000_000); // no overflow: the loop drives each stepBreak big workloads into async chunks
For work that must stay recursive-in-spirit but is too large, yield to the event loop between batches. setTimeout and setImmediate both queue their callback as a macrotask, which runs on a fresh, empty call stack after the event loop has had a chance to handle I/O and other pending work:
function processAll(items, i = 0) {
const end = Math.min(i + 1000, items.length);
for (; i < end; i++) doWork(items[i]);
if (i < items.length) {
// Fresh stack on the next tick; also unblocks the event loop
setImmediate(() => processAll(items, i));
}
}This keeps a server responsive during a long job as a bonus, since it stops one CPU-bound loop from starving every other request.
Do not reach for queueMicrotask here. It also resets the call stack to empty before each callback, so it avoids the RangeError too, but microtasks all drain before the event loop moves on to I/O or timers. A chunked loop rebuilt on queueMicrotask never actually yields, so it blocks everything else exactly like the synchronous version did, just without the stack overflow.
When await breaks the recursion
Recursion through await does not grow the call stack the way synchronous recursion does. Each await suspends the function and resumes it later as a microtask, on a fresh, empty stack. So a promise-driven recursion runs to enormous depths without a RangeError:
async function loop(n) {
if (n === 0) return;
await Promise.resolve(); // suspends here; the stack unwinds
return loop(n - 1);
}
loop(1_000_000); // no stack overflowThis is a real escape hatch when you cannot restructure the recursion, but it is not free. The pending promise chain and queued microtasks live on the heap, so a truly unbounded version trades a stack overflow for slow, growing memory use, and it monopolizes the microtask queue while it runs. The await is what breaks the stack: a synchronous recursive call inside an async function, with no await between the calls, still overflows.
Deeply nested JSON overflows the parser
JSON.parse recurses over nested structure, so deeply nested input overflows the stack inside the parser itself, before your code ever touches the data:
const evil = '['.repeat(100_000) + ']'.repeat(100_000);
JSON.parse(evil); // RangeError: Maximum call stack size exceededThis matters most for untrusted input. Someone who can send you a deeply nested JSON body can crash the request, and in some setups the whole process, with a payload of only a few hundred kilobytes. It is a denial-of-service vector, not just a bug, and a request-body size limit does not stop it because the payload is small. It is the nesting, not the size, that overflows. Bound the depth before parsing anything you did not generate:
function maxDepth(str) {
let depth = 0, max = 0, inString = false, escaped = false;
for (const ch of str) {
if (escaped) { escaped = false; continue; }
if (ch === '\\' && inString) { escaped = true; continue; }
if (ch === '"') inString = !inString;
else if (!inString && (ch === '{' || ch === '[')) max = Math.max(max, ++depth);
else if (!inString && (ch === '}' || ch === ']')) depth--;
}
return max;
}
if (maxDepth(body) > 100) throw new Error('JSON nested too deep');
const data = JSON.parse(body);If you cannot bound the input in advance, such as a generic upload endpoint, parse it inside a worker_threads Worker instead of on the main thread. An uncaught overflow inside a worker emits an 'error' event on the Worker object and terminates only that worker; it does not crash the process the way an uncaught overflow on the main thread can:
const { Worker } = require('worker_threads');
const worker = new Worker('./parse-untrusted.js', { workerData: body });
worker.on('error', (err) => {
console.error('Untrusted payload failed to parse:', err.message);
});
worker.on('message', (data) => { /* use the parsed result */ });React: “Maximum update depth exceeded” and render loops
In React you are more likely to see React’s own guard than the raw engine error:
Error: Maximum update depth exceeded. This can happen when a component
repeatedly calls setState inside componentWillUpdate or componentDidUpdate.React throws this after about 50 nested re-renders to stop an infinite loop before the engine’s stack does. The usual causes are calling a state setter during render, or an effect that updates state it also depends on:
// BUG: setState during render triggers a render, which sets state again
function Counter() {
const [count, setCount] = useState(0);
setCount(count + 1); // infinite loop
return <span>{count}</span>;
}// BUG: effect updates a value in its own dependency array
useEffect(() => {
setData({ ...data, ready: true });
}, [data]); // every update re-runs the effectSet state inside an event handler or a properly scoped effect, never during render. These loops are covered in depth in Fix: React Too Many Re-renders and Fix: React useEffect Infinite Loop. A component that renders itself recursively (a tree view, a comment thread) also needs a base case, exactly like a plain recursive function.
Synchronous event and update loops
An event handler that synchronously fires the same event recurses on the call stack, because dispatchEvent runs its listeners immediately rather than queuing them:
// BUG: the handler re-dispatches the event it is handling
el.addEventListener('sync', () => {
el.dispatchEvent(new Event('sync')); // RangeError
});
el.dispatchEvent(new Event('sync'));State libraries hit the same wall. A Redux subscriber that dispatches during notification re-runs the reducers and re-notifies every subscriber, synchronously, until the stack fills:
// BUG: dispatching inside a subscriber re-enters the update loop
store.subscribe(() => {
store.dispatch({ type: 'PING' }); // recurses through every subscriber
});Break the synchronous loop: guard against re-entry with a flag, defer the follow-up with queueMicrotask or setTimeout, or restructure so an update never triggers itself. Note that a MutationObserver or a setTimeout loop does not overflow the stack, because those run asynchronously on a fresh stack. They hang the tab or leak memory instead, which is a different failure to chase.
Raise the stack size as a last resort
You can give V8 a larger stack, but treat this as a stopgap, not a fix:
node --stack-size=4000 app.jsThe value is in kilobytes and it is dangerous. The V8 stack sits inside the operating system thread stack, which defaults to about 8 MB on Linux and 1 MB for the main thread on Windows. Set --stack-size above that OS limit and instead of a catchable RangeError you get a hard segmentation fault that crashes the process with no stack trace. Raising the limit also only buys a constant factor; truly unbounded recursion still overflows, just a little later. Fix the recursion instead whenever you can.
Can you catch a stack overflow?
Technically yes. The overflow throws a RangeError you can wrap in try/catch:
try {
recurse();
} catch (err) {
if (err instanceof RangeError) {
// reached, but with very little stack left to work with
}
}In practice, do not build control flow on it. As the exception unwinds it frees stack space, so a catch far above the overflow has room, but a catch close to the recursive call runs with almost none, and a single non-trivial call inside the handler can overflow again immediately. Worse, an overflow thrown from an async callback with no surrounding try/catch becomes an uncaughtException and takes the whole Node process down. Treat Maximum call stack size exceeded as a bug to fix at the source, not a condition to recover from.
Still overflowing?
Circular references in your own traversal. A tree where a child points back to an ancestor makes any recursive walk loop forever. Track visited nodes:
function walk(node, seen = new Set()) {
if (seen.has(node)) return; // stop at a cycle
seen.add(node);
for (const child of node.children) walk(child, seen);
}Note that JSON.stringify on a circular object throws a different error, TypeError: Converting circular structure to JSON, not a stack overflow. If you wrote your own recursive serializer, though, a cycle gives you the RangeError. For deep-cloning, prefer the built-in structuredClone(obj) over a hand-rolled recursive clone: it tracks visited objects and follows cycles without looping, where a naive recursive clone overflows on them.
Mutual recursion you did not notice. Two functions that call each other (isEven calls isOdd calls isEven) overflow for large inputs the same as direct recursion. The stack trace shows both names alternating. Convert to a loop or add a shared depth guard.
A library recursing on your data. Deep-clone, deep-merge, and schema-validation libraries recurse over the object you hand them. Pass a deeply nested or circular object and the overflow happens inside the library, not your code. The trace points into node_modules. Flatten or bound the data before the call.
The same bug in another language. The concept is identical across runtimes, only the error name changes. See Fix: Java StackOverflowError and Fix: Python RecursionError for the JVM and Python versions, which need the same base-case and iteration fixes.
It only overflows in production. Minifiers can inline functions and change how deep a call chain gets, and different Node versions ship different V8 stack limits. If it reproduces only after a build, test against the exact Node version your server runs, not just your local one.
For related runtime errors, see Fix: JavaScript Heap Out of Memory, Fix: React Too Many Re-renders, Fix: React useEffect Infinite Loop, and Fix: Java StackOverflowError.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.
Fix: Blurhash Not Working — Placeholder Not Rendering, Encoding Failing, or Colors Wrong
How to fix Blurhash image placeholder issues — encoding with Sharp, decoding in React, canvas rendering, Next.js image placeholders, CSS blur fallback, and performance optimization.
Fix: Embla Carousel Not Working — Slides Not Scrolling, Autoplay Not Starting, or Thumbnails Not Syncing
How to fix Embla Carousel issues — React setup, slide sizing, autoplay and navigation plugins, loop mode, thumbnail carousels, responsive breakpoints, and vertical scrolling.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.