Ch. 1 · JavaScript

RangeError: Maximum Call Stack Size Exceeded in JavaScript

Fix RangeError: Maximum call stack size exceeded: missing base cases, self-calling setters, circular data, huge spreads and deep recursion.

~8 min readintermediateupdated Oct 4, 2026

Chrome, Edge and Node.js (V8) report this when the call stack runs out of room:

RangeError: Maximum call stack size exceeded
Text

Every function call that has not returned yet occupies a frame on the call stack. The stack has a fixed size, so if calls keep nesting without returning, the engine eventually throws. Usually that is infinite recursion: a function that calls itself (directly or through others) forever. Sometimes the recursion is finite but just too deep, and occasionally there is no recursion at all.

Firefox reports the same condition as InternalError: too much recursion, and Safari as RangeError: Maximum call stack size exceeded.

Quick fix checklist

  • Look at the stack trace: the frames that repeat are the cycle you need to break.
  • Check that every recursive function has a base case, and that each call moves towards it.
  • Check getters and setters: set name(v) { this.name = v } calls itself.
  • Check toJSON, toString and valueOf methods that call JSON.stringify(this) or template literals on this.
  • For recursive walks over objects (deep clone, deep equal, tree render), check for circular references.
  • Replace Math.max(...hugeArray) and arr.push(...hugeArray) with a loop or reduce.
  • For legitimately deep structures, switch to iteration with an explicit stack.

Before you start

You should understand what recursion is (a function solving a problem by calling itself on a smaller part) and be able to read a basic stack trace. The examples run in Node.js 18 or newer; the output shown is from Node 22.

Why it happens

The engine allocates a block of memory for the call stack when a thread starts (roughly 1 MB on 64-bit Node by default; browsers differ). Each call pushes a frame with its arguments, local variables and return address. How many frames fit depends on how big each frame is, so the maximum depth is not a fixed number: it might be ten thousand for a function with many locals and tens of thousands for a tiny one.

The causes fall into five groups.

1. Missing or unreachable base case.

function countdown(n) {
  if (n === 0) return;
  countdown(n - 2); // skips 0 for odd n, so it never stops
}
try { countdown(5); } catch (e) { console.log(String(e)); }
// RangeError: Maximum call stack size exceeded
JavaScript

The base case exists, but 5, 3, 1, -1, ... never hits zero. Use n <= 0, which covers every path.

2. Accidental recursion through accessors and conversion methods.

class User {
  set name(value) { this.name = value.trim(); } // assigns to itself
}
try { new User().name = ' Ana '; } catch (e) { console.log(String(e)); }

const money = { cents: 500, toJSON() { return JSON.stringify(this); } };
try { JSON.stringify(money); } catch (e) { console.log(String(e)); }
// RangeError: Maximum call stack size exceeded
// RangeError: Maximum call stack size exceeded
JavaScript

this.name = ... inside the name setter calls the setter again. JSON.stringify calls toJSON, which calls JSON.stringify on the same object, which calls toJSON… The fixes store data somewhere else and return plain data:

class User {
  #name = '';
  get name() { return this.#name; }
  set name(value) { this.#name = value.trim(); }
}
const u = new User();
u.name = ' Ana ';
console.log(JSON.stringify(u.name));

const money = { cents: 500, toJSON() { return { cents: this.cents, display: '$5.00' }; } };
console.log(JSON.stringify(money));
// "Ana"
// {"cents":500,"display":"$5.00"}
JavaScript

3. Mutual recursion. a calls b and b calls a. Each looks fine alone; the cycle only shows in the stack trace. A classic example is isEven(n) calling isOdd(n - 1) and vice versa, which overflows for negative input because neither reaches zero. The same shape appears in UI code when a change handler updates state that triggers the same handler.

4. Circular data in recursive algorithms. A naive deep clone, deep equality check or tree printer follows references. If a child points back to its parent, the walk never ends. (JSON.stringify detects this case and throws TypeError: Converting circular structure to JSON instead.)

5. Too many arguments. Spreading an array into a call passes every element as an argument, and arguments live on the stack:

for (const n of [100_000, 200_000]) {
  const big = Array.from({ length: n }, (_, i) => i);
  try { console.log(n, Math.max(...big)); } catch (e) { console.log(n, String(e)); }
}
// 100000 99999
// 200000 RangeError: Maximum call stack size exceeded
JavaScript

On this machine the limit was between 100,000 and 125,000 elements; it varies by engine, platform and stack size, so do not rely on any number. fn.apply(null, arr) and arr.push(...other) have the same limit.

Step-by-step walkthrough

Step 1: Reproduce with the smallest input

Find the input that triggers it. Infinite recursion fails on every input that reaches the loop; depth problems fail only on large or deeply nested inputs. That distinction already tells you which kind of fix you need.

Step 2: Read the repeating frames

Here is what Node prints for the self-assigning setter (paths shortened):

trace.js:3
    this.theme = value;
               ^

RangeError: Maximum call stack size exceeded
    at set theme (trace.js:3:16)
    at set theme (trace.js:3:16)
    at set theme (trace.js:3:16)
    at set theme (trace.js:3:16)
Text

V8 keeps only the top ten frames by default, so you see the cycle, not where it started. A single repeated frame means direct recursion. Alternating frames (isEven, isOdd, isEven…) mean mutual recursion. If you need the entry point, run Node with --stack-trace-limit=200, or in the browser pause on exceptions in DevTools and scroll the Call Stack panel to the bottom.

Step 3: Decide: bug or legitimate depth?

If the recursion should terminate quickly but does not, it is a logic bug: fix the base case, the accessor or the cycle. If it would terminate but the data is simply deep (a 50,000-message reply chain, a long linked list), the algorithm is correct and the call stack is the wrong tool.

Step 4: Convert deep recursion to iteration

Move the “work still to do” from the call stack to an array you control. The heap is far larger than the stack:

// Build a comment thread nested 50,000 levels deep (a reply chain).
let root = { id: 0, replies: [] };
let node = root;
for (let i = 1; i < 50_000; i++) {
  const next = { id: i, replies: [] };
  node.replies.push(next);
  node = next;
}

function countRecursive(n) {
  let total = 1;
  for (const r of n.replies) total += countRecursive(r);
  return total;
}

function countIterative(start) {
  let total = 0;
  const stack = [start];
  while (stack.length > 0) {
    const n = stack.pop();
    total += 1;
    for (const r of n.replies) stack.push(r);
  }
  return total;
}

try { console.log(countRecursive(root)); } catch (e) { console.log(String(e)); }
console.log(countIterative(root));
// RangeError: Maximum call stack size exceeded
// 50000
JavaScript

JavaScript engines (other than Safari’s) do not implement tail-call optimisation, so rewriting the recursion as a tail call does not help in Chrome or Node.

Worked scenario

A settings page lets users duplicate a project. The duplicate button crashes the tab with Maximum call stack size exceeded for some projects only. The clone function:

function deepClone(value) {
  if (value === null || typeof value !== 'object') return value;
  const copy = Array.isArray(value) ? [] : {};
  for (const key of Object.keys(value)) copy[key] = deepClone(value[key]);
  return copy;
}
const parent = { name: 'root', children: [] };
const child = { name: 'leaf', parent };
parent.children.push(child);
try { deepClone(parent); } catch (e) { console.log(String(e)); }
// RangeError: Maximum call stack size exceeded
JavaScript

Diagnosis. The trace repeats deepClone frames. Failing projects are the ones whose tasks have subtasks, and subtasks keep a parent reference for navigation: parent.children[0].parent === parent. The clone walks parent, child, parent, child forever.

Fix. Remember what has already been copied, and return the existing copy when a value is seen again. This also preserves the cycle in the copy:

function deepCloneSafe(value, seen = new Map()) {
  if (value === null || typeof value !== 'object') return value;
  if (seen.has(value)) return seen.get(value);
  const copy = Array.isArray(value) ? [] : {};
  seen.set(value, copy);
  for (const key of Object.keys(value)) copy[key] = deepCloneSafe(value[key], seen);
  return copy;
}
const parent = { name: 'root', children: [] };
parent.children.push({ name: 'leaf', parent });
const c = deepCloneSafe(parent);
console.log(c.children[0].parent === c, c !== parent);
console.log(structuredClone(parent).children[0].parent.name);
// true true
// root
JavaScript

For plain data, the built-in structuredClone already handles cycles, as the last line shows. It cannot clone functions or DOM nodes, which is when a custom clone is still needed.

Common mistake

Raising the stack size. node --stack-size=... changes V8’s assumption about the stack without changing the operating system’s actual thread stack, so large values can crash the process with a segmentation fault instead of a catchable error. Even when it works, a slightly deeper input fails again. Fix the cycle or use iteration.

Catching the RangeError and retrying. If the recursion is infinite, a retry overflows again; if it is merely deep, the work done so far is lost. Neither outcome helps.

Wrapping the recursive call in setTimeout or a promise. It does avoid the overflow, because each step starts on a fresh stack, but infinite recursion becomes an infinite loop of tasks that freezes the page or burns CPU instead of failing fast.

Verify the behavior

Test the iterative version with a depth that previously overflowed, and test the safe clone with cycles:

import assert from 'node:assert/strict';

function sumIterative(head) {
  let total = 0;
  for (let n = head; n; n = n.next) total += n.value;
  return total;
}

let head = null;
for (let i = 0; i < 200_000; i++) head = { value: 1, next: head };
assert.equal(sumIterative(head), 200_000);

const parent = { name: 'root', children: [] };
parent.children.push({ name: 'leaf', parent });
const copy = structuredClone(parent);
assert.equal(copy.children[0].parent, copy);
assert.notEqual(copy, parent);
console.log('deep and circular inputs handled');
// deep and circular inputs handled
JavaScript

Include the deepest realistic input in your test suite, not only small examples, so the overflow cannot quietly return.

Interview exercise

“This flattens nested arrays. When does it fail, and how would you make it robust?”

const flatten = (arr) => arr.reduce((acc, x) => acc.concat(Array.isArray(x) ? flatten(x) : x), []);
JavaScript

Answer and reasoning

It recurses once per nesting level, so it overflows on arrays nested tens of thousands of levels deep. It also never terminates on a self-containing array (a.push(a)), because Array.isArray(a) stays true forever. As a secondary issue, concat copies the accumulator each time, which is quadratic for wide arrays. A robust version uses an explicit stack: push the input, pop an item, push its children if it is an array (in reverse to keep order), or append it to the result otherwise; add a Set of visited arrays if cycles are possible. The built-in arr.flat(Infinity) is fine for normal data, but it is not a cure either: on Node 22 it also threw this RangeError for an array nested 100,000 levels deep. I would also mention that tail-call optimisation is not a fix in V8, so making the recursion tail-recursive does not change the limit.

Continue learning

Practise more with the JavaScript interview questions and the JavaScript MCQs. Accessor recursion is easier to spot once you know this keyword binding rules, and if the stack trace shows a function that is not a function, see x is not a function. References: MDN’s too much recursion, structuredClone() and the note on argument limits in Math.max().

More in JavaScript

esc