Why Recursion Causes Maximum Call Stack Size Exceeded

RangeError: Maximum call stack size exceeded is a memory error, but not the kind the rest of this site is about: it is the stack running out, not the heap. This page belongs to stack vs heap memory allocation in JavaScript within JavaScript Memory Fundamentals & Runtime Mechanics.

Symptom Root Cause Immediate Action
Two or three frames repeating in the trace Unbounded recursion, no base case Add the missing termination condition
One frame repeated thousands of times Legitimate depth exceeding the stack Convert to iteration with an explicit array
Works on small inputs, fails on large ones Depth scales with input size Same conversion; depth then bounded by heap
Fails at different depths on different machines Limit depends on frame size and engine Never rely on a specific depth
Node segfaults instead of throwing --stack-size raised above the OS thread limit Lower it below the OS limit, or convert

Root Cause: A Fixed Region With Variable-Sized Frames

The call stack is a fixed-size region allocated when the thread starts — typically under a megabyte for the main thread. Every function call pushes a frame containing the return address, the arguments, the local variables and any spilled registers. Frames are popped on return. The region does not grow.

That is why there is no single depth limit. A frame’s size depends on how many parameters and locals the function has, so a two-argument function with no locals might manage 15 000 frames while a function holding a dozen locals exhausts the same region at 6 000. Engines differ, thread stacks differ between the main thread and workers, and the number moves between browser versions. Any code that depends on a specific depth is depending on an accident.

The distinction that matters diagnostically is between unbounded and deep. Unbounded recursion — a missing base case, or two functions calling each other — shows as a short cycle repeating in the trace, and the fix is the termination condition. Genuine depth — walking a linked list of 50 000 nodes, or a deeply nested document — shows as one frame repeated, and no stack size will ever be enough because the depth is a function of the input.

The conversion for the second case is always the same shape: move the pending work from the stack into an array on the heap. The algorithm is unchanged; the state simply lives somewhere that can grow. As covered in the parent guide, this does not reduce memory use — it relocates it to a region measured in hundreds of megabytes rather than hundreds of kilobytes.

The same traversal, stack-resident versus heap-resident On the left, a recursive traversal pushes one frame per node into a fixed one megabyte stack region, which overflows at around eleven thousand nodes. On the right, an iterative traversal holds the pending nodes in an array on the heap, where fifty thousand entries occupy less than half a megabyte of a region measured in hundreds of megabytes. Identical algorithm, different storage for the pending work Recursive · frames on the stack walk(node) — frame 1 walk(node.next) — frame 2 … 11 000 more frames … RangeError: Maximum call stack size exceeded fixed region, typically < 1 MB Iterative · pending array on the heap while (stack.length) — frame 1 frame 2 — and that is all of them pending: Array(50 000) on the heap ≈ 0.4 MB of a region measured in hundreds depth now bounded by memory, not by the stack

Step-by-Step Fix

  1. Read the trace shape. Expected: a repeating cycle of two or three frames means unbounded; one frame repeated means depth.
  2. Measure the real limit for that function. Command: the probe below. Expected: a number specific to this engine and frame size — useful for understanding, never for depending on.
  3. For unbounded recursion, add the base case. Verification: the probe now terminates on its own.
  4. For genuine depth, convert to iteration. Verification: the same input that threw now completes, and depth scales with heap rather than stack.
  5. Test with an input several times deeper. Verification: an input ten times past the old failure point completes, which proves the limit is gone rather than moved.

Command & Code Reference

Measuring the achievable depth, which differs per function and per engine.

// The limit is a function of FRAME SIZE, so measure the function you care
// about rather than an empty one.
function maxDepth(fn) {
  let depth = 0;
  function probe() {
    depth++;
    return probe();           // no base case — deliberately overflows
  }
  try { probe(); } catch (e) {
    if (!(e instanceof RangeError)) throw e;
  }
  return depth;
}
console.log('empty frames:', maxDepth());   // e.g. ~11 000 in Chrome

The standard conversion: an explicit stack on the heap, with the same traversal order.

// Recursive: one frame per node, overflows on deep trees.
function sumRecursive(node) {
  if (!node) return 0;
  return node.value + sumRecursive(node.left) + sumRecursive(node.right);
}

// Iterative: two frames total, pending nodes held in an array that can grow
// into the hundreds of megabytes the heap has available.
function sumIterative(root) {
  let total = 0;
  const pending = [root];
  while (pending.length) {
    const node = pending.pop();
    if (!node) continue;
    total += node.value;
    pending.push(node.left, node.right);   // heap-allocated, not stack frames
  }
  return total;
}

Chunking work that must stay recursive, so the stack unwinds between batches.

// When a rewrite is too invasive, break the recursion with a scheduler:
// each batch runs to a bounded depth, then yields, and the stack unwinds.
async function processDeep(node, batchSize = 500) {
  let pending = [node];
  while (pending.length) {
    const batch = pending.splice(0, batchSize);
    for (const n of batch) {
      handle(n);
      if (n.children) pending.push(...n.children);
    }
    // Yielding returns to the event loop with an empty stack, which also
    // keeps the page responsive during a long traversal.
    await new Promise((r) => setTimeout(r, 0));
  }
}
Depth limit falls as frame size grows A function with no locals reaches about eleven thousand frames. With three locals it reaches around eight thousand two hundred. With eight locals, five thousand nine hundred. With a closure capturing a large scope, three thousand one hundred. The limit is a property of the function, not of the language. “The limit is about 10 000” is true only for the smallest possible frame no parameters or locals ~11 000 3 locals ~8 200 8 locals ~5 900 closure over a large scope ~3 100 Measured in one Chrome build on one machine — the ordering is stable, the numbers are not.

Verification & Regression Prevention

Verify with an input well past the previous failure point: if the old code broke at 11 000 nodes, test the new code at 100 000. A conversion that merely enlarged the frames — by moving locals around without removing the recursion — will fail again a little later, and only a much larger input reveals it.

For prevention, add a depth guard to any recursive function that walks user-supplied structure. A guard that throws a domain error at, say, 1 000 levels gives a message someone can act on, instead of a RangeError whose stack trace is 11 000 identical lines. And where recursion is genuinely the clearest expression of the algorithm, keep it and bound the input instead — validating that an uploaded document is not nested 40 000 levels deep is usually the more honest fix.

Which fix the trace is asking for A short repeating cycle in the trace means a missing base case. One frame repeated with depth tied to input size means convert to iteration. Depth driven by untrusted input means add a guard and validate the input instead. The stack trace tells you which of three fixes applies what repeats in the trace? a 2–3 frame cycle unbounded recursion — add the missing base case no rewrite needed one frame, N deep genuine depth — convert to an explicit heap array depth then limited by memory depth from user input guard at a sane level and reject the pathological input with a message that helps

FAQ

How deep can JavaScript recursion actually go?

It depends on frame size rather than a fixed count. A function with two parameters and no locals may reach 15 000 frames while one with a dozen locals reaches 6 000, in the same engine on the same machine. Node raises it with --stack-size, and browsers do not expose a setting at all, so treat any specific number as unportable.

Does tail-call optimisation help?

Not in practice. Proper tail calls are specified but shipped only in JavaScriptCore, so code relying on them breaks in V8 and SpiderMonkey. Convert to iteration instead; it is portable and usually clearer than restructuring a function to be tail-recursive.

Is raising --stack-size a reasonable fix in Node?

Only as a temporary measure, and carefully: the flag must stay below the operating system’s thread stack limit or the process segfaults instead of throwing a catchable error. A crash with no stack trace is strictly worse than the RangeError it replaced.