skip to content

In JavaScript, what makes an engine throw the error 'RangeError: Maximum call stack size exceeded', and why does rewriting the same recursive algorithm as a loop remove that limit?

level: middleimportance: should knowfreq 52%

answer

  1. frames are physical, not free
  2. the region is fixed at startup
  3. bytes per frame, not a call count
  4. a loop reuses one frame
  5. tail calls only help in JavaScriptCore

basics

~20 s

Every call in progress needs a stack frame, and the engine's stack region is fixed in size; recursion that nests deeply enough exhausts it and V8 reports that as a RangeError. A loop reuses one frame, so nothing accumulates.

solid answer

~50 s

Each call that has not yet returned occupies a frame, and the engine gives the stack a fixed amount of memory, so nesting calls deeply enough runs out of room. V8 surfaces that as `RangeError: Maximum call stack size exceeded`; SpiderMonkey uses a non-standard `InternalError: too much recursion`, so the exact type is engine-specific. There is no guaranteed depth either — the limit is measured in bytes, not calls, so a function with many parameters and locals has fatter frames and overflows sooner; in V8 you typically observe somewhere in the low tens of thousands of frames. A loop avoids this because each pass reuses a single frame and keeps its state in variables — or, if the algorithm is genuinely recursive in shape, in an explicit array used as a stack on the heap. Proper tail calls would help, but only JavaScriptCore implements them, so in Chrome, Node and Firefox tail recursion still overflows.

code

javascript · 11 lines
javascript
function depth(n = 1) {
  return depth(n + 1);
}

try {
  depth();
} catch (e) {
  console.log(e instanceof RangeError, e.name);
}
// V8 (Chrome, Node): true 'RangeError'
// SpiderMonkey (Firefox): false 'InternalError'

go deeper

for a junior

Know that unbounded recursion crashes because each unfinished call takes stack space, and that the immediate remedy is a correct base case or an iterative rewrite.

for a middle

Explain that the bound is on total frame bytes rather than a call count, that the error type differs between engines, and how a loop or an explicit array-based worklist removes the ceiling.

for a senior

Treat an overflow in production as a defect signal — cyclic data, a missing base case, a self-recursive getter — and be able to say why catching it is not a recovery strategy and why tail-call rewrites do not help on V8.

for a principal

Frame it as an input-driven risk: any recursion whose depth is a function of untrusted input is an availability hazard, so depth bounds or heap-based traversal belong in the design and the review checklist, not in a later patch.

## Why there is a limit at all A call that has started and not yet returned occupies a frame: its arguments, its local variables, and the point to resume at. Those frames live in a contiguous region of memory that the engine sizes once, at startup. Recursion piles frames without popping any — `f` calls `f` calls `f`, and no return happens until the base case is reached — so the region is consumed in proportion to recursion depth. When the next push would run past the end of it, the engine stops and throws. ```js function depth(n = 1) { return depth(n + 1); } try { depth(); } catch (e) { console.log(e.name); } // 'RangeError' in V8 ``` ## The error is engine-specific ECMAScript does not specify a stack limit or an error for exceeding it, so this is one of the rare everyday behaviours where engines differ visibly. V8 (Chrome, Edge, Node.js) throws a `RangeError` whose message is `Maximum call stack size exceeded`. SpiderMonkey (Firefox) throws `InternalError: too much recursion`, using a non-standard error type. JavaScriptCore (Safari) throws a `RangeError` with its own wording. Code that keys off the *message string* is therefore fragile, and code that keys off `instanceof RangeError` is portable to some engines but not all. ## The limit is bytes, not calls A common wrong mental model is "about ten thousand calls". The bound is on total frame *size*. A function with several parameters, many locals, and a `try` block has a bigger frame than a two-line helper, so the same engine will allow far fewer nested calls of the fat function. Measured depth also shifts between engine versions, between optimised and unoptimised code, and depending on how much stack the current job already consumed before the recursion started. Treat any number you measure as an observation, not a contract. The stack is not only consumed by explicit recursion. Spreading a very large array into a call passes each element as an argument, and arguments are placed on the stack: ```js const big = new Array(200000).fill(1); let name = 'no error'; try { Math.max(...big); } catch (e) { name = e.name; } console.log(name); // 'RangeError' where spread arguments go on the stack ``` The fix there is not recursion-related at all: reduce over the array instead of spreading it. ## Why a loop has no such ceiling An iterative version keeps exactly one frame alive. Each pass overwrites the same local variables rather than allocating new ones, so memory use is flat no matter how many iterations run: ```js function sumTo(n) { let total = 0; for (let i = 1; i <= n; i++) total += i; return total; } ``` For algorithms that are genuinely recursive in shape — tree walks, backtracking — you can convert them by maintaining your own stack in an array. That moves the pending work from the fixed-size call stack onto the heap, which is far larger and grows dynamically. The algorithm is the same; only the storage changes. ## Proper tail calls: specified, mostly unimplemented ES2015 specified *proper tail calls*: when a call is in tail position in strict-mode code, the engine reuses the current frame instead of pushing a new one, making tail recursion run in constant stack space. In practice only JavaScriptCore ships it. V8 and SpiderMonkey declined, largely because silently discarding frames destroys stack traces and makes debugging much harder. So in Chrome, Node and Firefox, writing a function in tail-recursive style gives you no stack relief at all, and it is a mistake to offer it as a fix without that caveat. ## Catching it The throw is an ordinary exception and `try`/`catch` will catch it, which is occasionally useful for probing depth in a test. It is a poor recovery mechanism in production: you are already at the edge of the stack, the `catch` handler itself needs room to run, and cleanup code that calls other functions may overflow again. If it appears in a real service, it is nearly always a symptom — unbounded recursion caused by a cycle in the data, a missing base case, or a getter that recurses into itself — and the fix belongs at the algorithm, not at the catch site.

  • Roughly how many nested calls can you make before it throws?
    There is no fixed answer, and quoting one is a trap. The limit is on total frame bytes, so a function with many parameters and locals overflows at a much smaller depth than a tiny helper. It also varies by engine, engine version, optimisation state, and how much stack the current job already used. In V8 you often observe the low tens of thousands, but that is a measurement, not a guarantee.
  • How would you convert a deep recursive tree walk without rewriting the algorithm's logic?
    Keep an explicit worklist: push the root into an array, then loop while the array is non-empty, popping a node, processing it, and pushing its children. The traversal order and the logic are unchanged; the pending work simply lives on the heap in an array instead of in call frames, and the heap grows dynamically rather than being a fixed region.
  • Is catching the overflow a reasonable way to make a recursive function safe?
    Rarely. The catch runs with the stack still nearly full, so any cleanup that calls other functions can overflow again, and the error type differs between engines so the catch is not portable. It also masks a defect — a missing base case or a cycle in the data — that will resurface elsewhere. Bound the depth explicitly, or make the algorithm iterative.

saying these in an interview costs you the question

  • The limit is a fixed number of calls, about ten thousand
  • Rewriting as tail recursion fixes it in every browser
  • It is a StackOverflowError, like in other languages
  • Stack overflow means the machine ran out of memory
  • Catching the RangeError makes the recursion safe

context