Node.js / JavaScript Error

RangeError: Maximum call stack size exceeded

error type: RangeError  ·  engine: V8  ·  also: RangeError: Maximum call stack size exceeded (V8)

Complete reference — every cause (missing base case, circular JSON, large push.apply, mutual recursion), every fix (trampoline, iterative stack, WeakSet replacer, structured-clone), and Node.js-specific debugging with --stack-trace-limit and the V8 inspector.

Quick Answer: RangeError: Maximum call stack size exceeded means V8 ran out of call-stack frames. The four most common causes in Node.js are: (1) a recursive function with a missing or wrong base case — add a correct termination condition or convert to iteration; (2) JSON.stringify on an object with a circular reference — use a WeakSet replacer or structuredClone; (3) Array.prototype.push.apply(target, hugeArray) or target.push(...hugeArray) with >~125k elements — use a for...of loop instead; (4) mutual recursion between two functions or modules with no exit — break the cycle with an iterative approach.

What is RangeError: Maximum call stack size exceeded?

Every time JavaScript calls a function, V8 pushes a stack frame onto the call stack. The frame holds the function's local variables, arguments, and the return address. When the function returns, V8 pops its frame off the stack. In a normal recursive or iterative program the stack depth stays bounded.

When recursion has no terminating path — or when a single operation expands into hundreds of thousands of nested calls — the stack fills completely. V8 enforces a hard limit (roughly 10,000–15,000 frames at Node.js default settings) and throws RangeError: Maximum call stack size exceeded. The process then terminates unless the error is caught very high up the call chain.

Exact error strings you will see in the terminal:
RangeError: Maximum call stack size exceeded
RangeError: Maximum call stack size exceeded (V8)
RangeError: Maximum call stack size exceeded at JSON.stringify (<anonymous>)
RangeError: Maximum call stack size exceeded at Array.push.apply (<anonymous>)

Full Error Example

// app.js — classic infinite recursion
function countdown(n) {
  return countdown(n - 1); // no base case
}
countdown(1000);

// Output:
RangeError: Maximum call stack size exceeded
    at countdown (/project/app.js:2:10)
    at countdown (/project/app.js:2:10)
    at countdown (/project/app.js:2:10)
    at countdown (/project/app.js:2:10)
    ... (repeating — the same function listed hundreds of times)
    at countdown (/project/app.js:2:10)
    at Object.<anonymous> (/project/app.js:5:1)

The key diagnostic signal is the same function name repeated throughout the stack trace. That function is in the recursive loop. The number of frames shown by default is capped at 10 — see the Debugging section below to expand it.

All Causes at a Glance

CauseTypical error contextFix summary
Missing or wrong base case in recursion Same function repeated in stack trace Add correct terminating condition; verify it is always reached
JSON.stringify on a circular object at JSON.stringify (<anonymous>) in stack WeakSet replacer, structuredClone, or flatted library
push.apply(target, hugeArr) or push(...hugeArr) at Array.push.apply in stack; array > ~125k elements for...of loop, chunked push, or concat
Mutual / indirect recursion between functions or modules Two or more different function names alternating in stack trace Break the cycle; add shared base case; convert to iteration
Recursive event or callback loop in a framework Event handler calling itself synchronously Debounce, emit with setImmediate, or restructure handler

Cause 1 – Missing or Wrong Base Case in Recursion

The most common cause. A recursive function calls itself on every invocation with no condition that terminates the chain, or the termination condition is correct in theory but never reached because the input never satisfies it (e.g., the argument is not reduced toward the base case on each step).

// CJS — wrong: no base case
function factorial(n) {
  return n * factorial(n - 1); // calls forever; n eventually becomes -Infinity
}

console.log(factorial(5)); // RangeError: Maximum call stack size exceeded

// Fixed: add the base case
function factorial(n) {
  if (n <= 1) return 1;          // base case — terminates the chain
  return n * factorial(n - 1);   // n always decreases toward 1
}

console.log(factorial(5)); // 120
// ESM equivalent — same logic applies
export function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1);
}

Wrong base case — condition never satisfied

// Wrong — base case checks n === 0 but n starts at 5.5 and steps by 1
// It skips 0 entirely (5.5 → 4.5 → 3.5 → 2.5 → 1.5 → 0.5 → -0.5 → ...)
function countdown(n) {
  if (n === 0) return 'done'; // never reached for non-integer n
  return countdown(n - 1);
}
countdown(5.5); // RangeError

// Fix — use <= 0 instead of === 0
function countdown(n) {
  if (n <= 0) return 'done';
  return countdown(n - 1);
}

Depth-guard safety net

For production code where the input depth is not fully controlled, add an explicit depth limit that throws a descriptive error instead of crashing with a generic RangeError.

function processTree(node, depth = 0) {
  if (depth > 1000) {
    throw new RangeError(
      `processTree: exceeded maximum depth of 1000 — possible circular reference in input`
    );
  }
  if (!node.children || node.children.length === 0) return node.value;

  return node.children.reduce(
    (sum, child) => sum + processTree(child, depth + 1),
    0
  );
}

Cause 2 – Circular Reference in JSON.stringify

JSON.stringify traverses the object graph recursively. If any object in the graph holds a reference back to one of its ancestors, the traversal loops forever and the call stack overflows.

// Circular reference — obj.self points back to obj
const obj = { name: 'root' };
obj.self = obj;

JSON.stringify(obj);
// RangeError: Maximum call stack size exceeded
//     at JSON.stringify (<anonymous>)

// Another common case: parent ↔ child back-reference
const parent = { id: 1, children: [] };
const child  = { id: 2, parent };
parent.children.push(child);

JSON.stringify(parent); // RangeError

Fix A — WeakSet-based replacer (zero dependencies)

function safeStringify(obj, indent) {
  const seen = new WeakSet();
  return JSON.stringify(obj, function (key, value) {
    if (typeof value === 'object' && value !== null) {
      if (seen.has(value)) {
        return '[Circular]';   // omit or replace the back-reference
      }
      seen.add(value);
    }
    return value;
  }, indent);
}

const obj = { name: 'root' };
obj.self = obj;

console.log(safeStringify(obj));
// {"name":"root","self":"[Circular]"}

Fix B — structuredClone to break the cycle first (Node.js 17+)

structuredClone is a global available in Node.js 17+ that performs a deep clone using the structured-clone algorithm. It does handle circular references — but the clone itself will still contain the cycle, so you cannot JSON.stringify the clone directly. Use it to produce a non-circular snapshot by breaking the cycle manually before cloning, or use it as a deep-copy mechanism unrelated to JSON.

// structuredClone handles circular references in the clone itself
const parent = { id: 1, children: [] };
const child  = { id: 2, parent };
parent.children.push(child);

// Deep-clone — the clone also has the circular structure
const cloned = structuredClone(parent); // does NOT throw

// To make it JSON-safe, remove back-references before cloning
const safeParent = {
  id: parent.id,
  children: parent.children.map(c => ({ id: c.id })), // drop .parent ref
};
console.log(JSON.stringify(safeParent));
// {"id":1,"children":[{"id":2}]}

Fix C — flatted library for full round-trip circular JSON

// npm install flatted
const { stringify, parse } = require('flatted'); // CJS
// import { stringify, parse } from 'flatted';    // ESM

const obj = { name: 'root' };
obj.self = obj;

const encoded = stringify(obj);
// '[{"name":"1","self":"0"},"root"]'  — a flat representation

const decoded = parse(encoded);
console.log(decoded.self === decoded); // true — circular ref restored

Cause 3 – Array.prototype.push.apply / Spread with a Huge Array

Function.prototype.apply passes the second argument array as individual arguments on the call stack. Each element occupies one stack slot. For arrays larger than roughly 125,000–150,000 elements, the arguments list overflows the stack. The same limit applies to the spread operator when used inside push.

const target = [];
const huge   = new Array(200_000).fill(0);

// Wrong — passes 200,000 arguments onto the stack
target.push.apply(target, huge);
// RangeError: Maximum call stack size exceeded
//     at Array.push.apply (<anonymous>)

// Also wrong for large arrays
target.push(...huge);
// RangeError: Maximum call stack size exceeded

Fix A — for...of loop (simplest, no limit)

// Safe for any array size — uses heap, not stack
for (const item of huge) {
  target.push(item);
}

Fix B — Array.prototype.concat (returns new array)

// concat does not use apply internally — safe for large arrays
// Note: creates a new array rather than mutating target
const result = target.concat(huge);

Fix C — chunked push for in-place mutation

// Chunked push — applies in batches of 10,000 to stay under the stack limit
const CHUNK = 10_000;
for (let i = 0; i < huge.length; i += CHUNK) {
  target.push(...huge.slice(i, i + CHUNK));
}
Rule of thumb: Only use push(...arr) or push.apply(target, arr) when arr.length is known to be small (under ~10,000 elements). For arrays of unknown or unbounded size, always use a loop or concat.

Cause 4 – Mutual / Indirect Recursion Across Modules

Mutual recursion occurs when function A calls function B, which calls function A again, forming a cycle with no exit. This is particularly sneaky across module boundaries because no single file reveals the loop on its own.

// parser.js (CJS)
const { handleExpression } = require('./evaluator');

function parseStatement(node) {
  if (node.type === 'ExpressionStatement') {
    return handleExpression(node.expression); // calls evaluator
  }
  return parseStatement(node); // bug: never delegates, recurses directly
}

module.exports = { parseStatement };

// evaluator.js (CJS)
const { parseStatement } = require('./parser');

function handleExpression(expr) {
  if (expr.type === 'BlockStatement') {
    return parseStatement(expr); // calls parser — mutual recursion formed
  }
  // missing base case for other node types — recurses through parser forever
  return handleExpression(expr);
}

module.exports = { handleExpression };
// Stack trace reveals the alternating cycle:
// RangeError: Maximum call stack size exceeded
//     at handleExpression (/project/evaluator.js:4:12)
//     at parseStatement (/project/parser.js:3:12)
//     at handleExpression (/project/evaluator.js:4:12)
//     at parseStatement (/project/parser.js:3:12)
//     ...

// Fix — add base cases that cover all node types in both functions
// evaluator.js
function handleExpression(expr) {
  if (expr.type === 'Literal')         return expr.value;
  if (expr.type === 'Identifier')      return lookupVariable(expr.name);
  if (expr.type === 'BlockStatement')  return parseStatement(expr);
  throw new TypeError(`Unhandled expression type: ${expr.type}`);
}

// parser.js
function parseStatement(node) {
  if (node.type === 'ExpressionStatement') return handleExpression(node.expression);
  if (node.type === 'ReturnStatement')     return handleExpression(node.argument);
  throw new TypeError(`Unhandled statement type: ${node.type}`);
}

ESM mutual recursion — same pattern

// a.mjs
import { b } from './b.mjs';
export function a(n) {
  if (n <= 0) return 'done'; // base case must exist in at least one branch
  return b(n - 1);
}

// b.mjs
import { a } from './a.mjs';
export function b(n) {
  if (n <= 0) return 'done'; // both sides need terminating conditions
  return a(n - 1);
}

Cause 5 – Recursive Event / Callback Loop

Synchronous event emitters (EventEmitter with emit called inside a listener) or recursive callback patterns can build up the stack just like direct recursion.

const { EventEmitter } = require('events');
const ee = new EventEmitter();

// Wrong — listener immediately re-emits the same event synchronously
ee.on('data', (value) => {
  console.log(value);
  ee.emit('data', value + 1); // synchronous, instant stack growth
});

ee.emit('data', 0);
// RangeError: Maximum call stack size exceeded

// Fix — schedule the next emission asynchronously with setImmediate
ee.on('data', (value) => {
  console.log(value);
  if (value < 10) {
    setImmediate(() => ee.emit('data', value + 1)); // yields to event loop
  }
});

Safe Recursion Patterns

Pattern A — Explicit iterative stack (replace recursion with a loop)

The most reliable fix for any recursive algorithm is to convert it to an explicit stack. Heap memory is orders of magnitude larger than the call stack, so depth is limited only by available RAM.

// Recursive tree walk — breaks on deep trees
function sumTree(node) {
  if (!node) return 0;
  return node.value + sumTree(node.left) + sumTree(node.right);
}

// Iterative equivalent — safe for arbitrary depth
function sumTreeIterative(root) {
  if (!root) return 0;
  let total = 0;
  const stack = [root];
  while (stack.length > 0) {
    const node = stack.pop();
    total += node.value;
    if (node.right) stack.push(node.right);
    if (node.left)  stack.push(node.left);
  }
  return total;
}

// Works on trees thousands of levels deep
const deepRoot = Array.from({ length: 50_000 }, (_, i) => ({ value: i }))
  .reduce((child, parent) => { parent.left = child; return parent; });

console.log(sumTreeIterative(deepRoot)); // no RangeError

Pattern B — Trampoline (constant call-stack depth)

A trampoline lets you write code in a tail-recursive style without growing the stack. Instead of calling itself, the function returns a thunk (a zero-argument function wrapping the next step). The trampoline driver loops, calling each thunk until the result is a plain value.

// Generic trampoline driver
function trampoline(fn) {
  return function trampolined(...args) {
    let result = fn(...args);
    while (typeof result === 'function') {
      result = result(); // call the thunk — stays at depth 2, never grows
    }
    return result;
  };
}

// Tail-recursive factorial using a thunk
function factTail(n, acc = 1) {
  if (n <= 1) return acc;
  return () => factTail(n - 1, n * acc); // return thunk instead of calling
}

const factorial = trampoline(factTail);

console.log(factorial(100_000)); // Infinity (too large for Number, but no RangeError!)
// For BigInt precision:
function factTailBig(n, acc = 1n) {
  if (n <= 1n) return acc;
  return () => factTailBig(n - 1n, n * acc);
}
const factorialBig = trampoline(factTailBig);
console.log(factorialBig(10_000n)); // exact large integer, no RangeError

Pattern C — Promise-based recursion (async trampolining)

Wrapping each recursive step in a Promise forces it through the microtask queue, breaking the synchronous stack chain. Useful for async recursive algorithms.

// Each recursive step resolves asynchronously — stack never grows beyond 2 frames
async function processChain(items, index = 0) {
  if (index >= items.length) return [];
  const result = await processItem(items[index]);
  const rest   = await processChain(items, index + 1); // async, not sync recursion
  return [result, ...rest];
}

// Better for very long chains — avoid array spread growth
async function processChainIterative(items) {
  const results = [];
  for (const item of items) {
    results.push(await processItem(item));
  }
  return results;
}

Debugging Checklist

  1. Expand the stack trace. Node.js shows only 10 frames by default. Run with node --stack-trace-limit=100 app.js to see the full recursion chain. Identify the repeating function name — that is your entry point into the cycle.
  2. Set Error.stackTraceLimit at startup (programmatic alternative): Error.stackTraceLimit = 50; at the top of your entry file so stack traces are always richer in development.
  3. Look for the alternating pattern. If two different function names alternate in the stack, you have mutual recursion. Check cross-module call chains.
  4. Use the V8 inspector. Run node --inspect app.js and open chrome://inspect. Set a breakpoint inside the suspected recursive function. The Call Stack panel in Chrome DevTools shows the current depth in real time.
  5. Add a depth counter. Temporarily instrument the function: function recurse(n, _d=0) { if (_d>500) { console.trace('depth 500 reached'); process.exit(1); } ... } to get a stack trace at an earlier depth before the crash.
  6. Check JSON.stringify calls. If the stack trace shows JSON.stringify near the top, inspect the object being serialized for circular references: console.log(Object.keys(suspect)) and check for back-references to parent objects.
  7. Check array sizes before push.apply / spread. Add console.log(hugeArr.length) before the failing push. If it exceeds ~100k, switch to a loop.
  8. Use --stack-size to confirm the cause. If bumping node --stack-size=65536 app.js (64 MB stack) merely delays the crash by a fixed proportion, you have genuine infinite recursion and must fix the logic. If it eliminates the crash entirely, your recursion is bounded but deeper than the default stack — consider converting to iteration anyway.
Do not catch RangeError as a control-flow mechanism. try { recursiveFn(); } catch (e) { if (e instanceof RangeError) ... } is unreliable — by the time V8 throws, the stack is full and even the catch block may not execute cleanly. Fix the recursion instead of catching the crash.

Node.js-Specific: --stack-size Flag

V8 exposes a --stack-size flag that sets the maximum stack size per thread in kilobytes. The default in Node.js is typically 984 KB on 64-bit systems.

# Increase stack size to 64 MB — useful for diagnosing, not for production
node --stack-size=65536 app.js

# Or set via NODE_OPTIONS for all node processes in a shell session
export NODE_OPTIONS="--stack-size=65536"
node app.js

# Docker/container environments — pass via CMD or ENTRYPOINT
CMD ["node", "--stack-size=65536", "dist/server.js"]
--stack-size is not a fix. Increasing the stack only buys more room before the same crash occurs. Infinite recursion will still crash — just after more frames. Bounded but deep recursion (e.g., a 50,000-node linked list traversed recursively) is the one legitimate use case for --stack-size while you schedule a proper conversion to iteration.

--stack-size in Worker Threads

const { Worker } = require('worker_threads');

// Each worker gets its own stack — configure independently
const worker = new Worker('./heavy-task.js', {
  resourceLimits: {
    stackSizeMb: 64, // Node.js 12.16+ — per-worker stack limit in MB
  },
});

Frequently Asked Questions

What is RangeError: Maximum call stack size exceeded in Node.js?

It is thrown by the V8 engine when the call stack — the data structure tracking active function calls — runs out of space. Each function call pushes a frame; when a recursive chain never terminates the frames accumulate until V8 hits its limit (~10,000–15,000 frames by default) and throws this error. The process then terminates unless caught very high up the chain.

Why does JSON.stringify throw Maximum call stack size exceeded?

JSON.stringify walks the object graph recursively. If any object in the graph references one of its ancestors (a circular reference), the traversal loops forever and the call stack overflows. Fix with a WeakSet replacer: const seen = new WeakSet(); JSON.stringify(obj, (k, v) => { if (typeof v === 'object' && v !== null) { if (seen.has(v)) return '[Circular]'; seen.add(v); } return v; });

Why does Array.prototype.push.apply throw Maximum call stack size exceeded?

Function.prototype.apply spreads the source array as individual arguments onto the call stack. Each element uses one stack slot. For arrays larger than ~125,000–150,000 elements the stack overflows. The same applies to push(...hugeArr). Fix: use a for...of loop — for (const item of huge) target.push(item); — or target = target.concat(huge);.

How do I convert a recursive function to avoid this error?

Two main strategies: (1) Explicit iterative stack — replace the recursive function with a while loop and a manual array as the stack. Uses heap memory, so depth is limited only by RAM. (2) Trampoline — the function returns a thunk (zero-arg function) instead of calling itself. A driver loop calls thunks until a plain value is returned, keeping the call stack at constant depth.

Does the Node.js --stack-size flag fix Maximum call stack size exceeded?

node --stack-size=65536 app.js increases the V8 stack size (in KB) and delays the error. It is not a real fix — infinite recursion still crashes, just after more frames. Use --stack-size only as a temporary diagnostic workaround while you fix the root cause in code.

How do I debug Maximum call stack size exceeded in Node.js?

Run node --stack-trace-limit=100 app.js to see 100 stack frames instead of the default 10. The repeating function name identifies the recursion loop. Use node --inspect app.js and Chrome DevTools (chrome://inspect) to set a breakpoint inside the recursive function — the Call Stack panel shows depth in real time. Add Error.stackTraceLimit = 50; at the top of your entry file for richer traces in development.

What is mutual recursion and how does it cause this error?

Mutual (indirect) recursion is when function A calls function B which calls function A again, with no exit. In the stack trace, two (or more) different function names alternate rather than one repeating. This is common across module boundaries. The fix is identical to direct recursion: ensure at least one branch in each function reaches a base case that stops the chain.

Why does Maximum call stack size exceeded happen in production but not locally?

The error depends on input depth. Local tests use small datasets that stay within the default stack limit; production processes larger trees, longer linked lists, or bigger arrays that exceed it. Docker and cloud runtimes may also allocate smaller per-thread stack sizes than a developer workstation. The fix is to make the algorithm's stack usage independent of input size by converting to iteration.

How do I fix Maximum call stack size exceeded in Next.js / React?

In React, the most common cause is a useEffect or state-update loop. A state change triggers a re-render, which triggers another state update, which triggers another re-render, recursively. Fix by ensuring useEffect has the correct dependency array and does not unconditionally set state that it also depends on. Another cause is calling a recursive utility on deeply nested component props — convert that utility to iteration.

Related Errors