Stack limits & stack overflow
Learn why deep recursion throws stack overflow errors, how stack depth varies by frame and host, and how explicit stacks avoid crashes.
- 01Explain the limitDescribe why every unfinished function call uses stack space and why hosts enforce a finite maximum call stack size.
- 02Read stack overflow errorsRecognize V8 and JavaScriptCore
RangeErrormessages, Firefox'sInternalError: too much recursion, and what the spec leaves to engines. - 03Avoid deep-recursion crashesMeasure depth carefully, avoid relying on exact numbers, and convert unbounded recursion to an explicit stack or queue when data can be deep.
Why recursion can crash
A recursive function is a function that calls itself, directly or through other functions. The earlier Recursion and Advanced recursion lessons teach when that idea is useful. Here we focus on the engine limit that makes very deep recursion fail.
Every unfinished call needs a stack frame: a small record that lets the engine return to the caller with the right values. The previous lesson, Stack frames & calling conventions, covers what a frame holds. This lesson uses that mental model without repeating all of it.
A stack overflow happens when nested function calls need more call-stack space than the host reserved. In JavaScript, the usual symptom is a deep or infinite recursive call chain that throws before it can unwind.
The key word is host. ECMAScript defines how calls and returns behave, but it does not require one universal stack size or one universal error constructor for stack exhaustion.
Step through a tiny guarded recursion before measuring your real browser. The counter shows why unbounded recursion eventually reaches the host's stack limit.
script
function countUntilLimit(limit) { let depth = 0; function dive() { depth += 1; if (depth === limit) return depth; return dive(); } return dive();} The execution above is deliberately tiny. Line 2 stores a counter. Line 4 increments it. Line 6 calls the same function again, so another frame is now waiting. A real unguarded recursion keeps stacking calls until the host refuses another frame.
Maximum call stack size is stack space, not a call-count promise
The phrase “maximum call stack size” sounds like a number of calls, but engines usually enforce a byte budget for the native stack. If each frame is small, more calls fit. If each frame is large, fewer calls fit.
Picture plates stacked on a kitchen shelf. The shelf height is fixed. Small plates make a taller stack; large plates reach the top sooner. Function calls work the same way: stack space is limited, and frame shape decides how many calls fit.
- In real life: The shelf has limited height
- In JavaScript: The host reserves a finite stack
- In real life: Each plate takes vertical space
- In JavaScript: Each function call needs a frame
- In real life: Large plates mean fewer fit
- In JavaScript: More locals and parameters can reduce recursion depth
- In real life: Another shelf can have a different height
- In JavaScript: Another engine, thread, worker, or Node flag can give a different depth
Where the analogy stops: Plates are visible and uniform. Real stack frames include engine metadata, optimized layouts, and different host stack sizes.
| Question | Useful answer | Common mistake |
|---|---|---|
| What is limited | Bytes of native stack reserved by the host | Not a JavaScript-specified number of calls. |
| Why depth varies | Each frame needs return information, parameters, locals, temporaries, and engine bookkeeping | Bigger frames fit fewer times in the same stack budget. |
| Where hosts differ | Browser engine, device, OS thread stack, worker stack, and Node flags | Your measured number is only for that environment at that moment. |
| Node example | node --stack-size=<kB> changes V8's stack reservation | Use relations in tests; never promise one exact depth. |
Node exposes V8's stack reservation through node --stack-size=<kB>. That is useful for experiments, not a production fix for algorithms that can recurse through user-controlled data.
`RangeError` in V8 and JavaScriptCore, `InternalError` in Firefox
Stack overflow reporting is an implementation detail. In Node 22 and Chrome's V8, overflowing the call stack throws a RangeError with the message Maximum call stack size exceeded. MDN documents Safari's JavaScriptCore entry as the same RangeError message with a trailing period, and Firefox's SpiderMonkey entry as InternalError: too much recursion.
InternalError is not one of ECMAScript's standard error constructors. It is a Firefox-exposed constructor, so portable code should not require it to exist.
| Engine family | Error type | Message you are likely to see |
|---|---|---|
| V8 / Chrome / Node | RangeError | Maximum call stack size exceeded |
| JavaScriptCore / Safari | RangeError | Maximum call stack size exceeded. with a trailing period in MDN's browser table |
| SpiderMonkey / Firefox | InternalError | too much recursion; InternalError is not an ECMAScript standard error constructor |
| ECMAScript spec | No required stack-overflow error type | The language defines recursive calls, but each implementation chooses how to report stack exhaustion |
function measureSmallFrame() { let depth = 0; function dive() { depth += 1; return dive(); } try { dive(); } catch (error) { return { depth, name: error.name, message: error.message, range: error instanceof RangeError }; }} function measureLargeFrame() { let depth = 0; function dive(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z) { const localSum = a + b + c + d + e + f + g + h + i + j + k + l + m + n + o + p + q + r + s + t + u + v + w + x + y + z; depth += 1; if (localSum === -1) throw new Error("unreachable"); return dive(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z); } try { dive(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1); } catch (error) { return { depth, name: error.name, message: error.message, range: error instanceof RangeError }; }} console.log(JSON.stringify({ node: process.versions.node, v8: process.versions.v8, small: measureSmallFrame(), large: measureLargeFrame(),}));The lesson test runs this probe in child Node 22 processes with V8 12.4. It asserts relations only: smaller --stack-size gives a smaller depth, the large-frame function reaches fewer calls than the tiny one, and the thrown value is a RangeError with message Maximum call stack size exceeded.
The MDN page InternalError: too much recursion is the source for the cross-browser message table. The tests prove the V8/Node facts directly.
Measure depth, but label it honestly
A stack-depth measurement is a diagnostic. It should answer, “What happened in this host, for this function shape, right now?” It should not become a magic constant in your application.
The playground below runs two real probes in your browser. The small-frame function only increments a counter before recursing. The large-frame function carries many parameters and a local sum before recursing. You should expect different numbers, but do not expect a particular exact value.
function measureSmallFrame() { let depth = 0; function dive() { depth += 1; return dive(); } try { dive(); } catch (error) { return { depth, name: error.name, message: error.message }; }} function measureLargeFrame() { let depth = 0; function dive(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z) { const localSum = a + b + c + d + e + f + g + h + i + j + k + l + m + n + o + p + q + r + s + t + u + v + w + x + y + z; depth += 1; if (localSum === -1) throw new Error("unreachable"); return dive(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z); } try { dive(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1); } catch (error) { return { depth, name: error.name, message: error.message }; }}No measurement yet. Press the button once; Reset clears the local result.
Press Measure my browser to count until the real browser throws, then compare a tiny frame with a larger one.
Line 4 in the small probe increments the counter. Line 18 in the large probe increments a separate counter after a much wider parameter list and local expression. Both catch the thrown error so the page can show the measurement instead of crashing the lesson.
If your browser reports 12000 calls today, another browser, device, worker, extension environment, or future version can report a different number. Use the measurement to understand risk, not to choose a production recursion limit.
Frame size changes how deep recursion gets
The Node proof uses the same stack reservation for two functions. The tiny function has almost no frame data. The large function has many parameters and a local expression. The large-frame function overflows sooner because each call consumes more stack bytes.
This is why a benchmark that says “my browser handles about N calls” is incomplete. You need to know which function shape produced N, and you need to know the host.
The stable fact is not “Node can recurse exactly this many times.” The stable facts in the test are relational: smaller stack reservation means fewer calls, and a larger frame means fewer calls under the same reservation.
Convert recursion to iteration when depth is unbounded
Recursion is often the clearest way to describe tree-shaped data. It is safe when you can prove the depth is small. It is risky when the depth comes from users, APIs, imports, or content management systems.
function makeDeepList(depth) { let node = null; for (let i = 0; i < depth; i += 1) { node = { value: 1, next: node }; } return node;} function sumRecursive(node) { if (node === null) return 0; return node.value + sumRecursive(node.next);} const deepList = makeDeepList(100000);console.log(sumRecursive(deepList));Line 14 builds a 100000-node list. Line 15 calls the recursive sum. Line 11 is the problem: every node adds another unfinished function call before any call can return.
function makeDeepList(depth) { let node = null; for (let i = 0; i < depth; i += 1) { node = { value: 1, next: node }; } return node;} function sumWithExplicitStack(root) { let total = 0; const pending = [root]; while (pending.length > 0) { const node = pending.pop(); if (node === null) continue; total += node.value; pending.push(node.next); } return total;} const deepList = makeDeepList(100000);console.log(sumWithExplicitStack(deepList));Line 11 creates pending, an array that stores work on the heap. Line 13 pops one node. Line 15 adds its value. Line 16 pushes the next node. The JavaScript call stack stays shallow, and the program prints 100000.
Replay the iterative version. The array named pending is your to-do list; the call stack stays shallow while the heap stores pending work.
script
const list = { value: 1, next: { value: 2, next: { value: 3, next: null } } }; function sumWithExplicitStack(root) { let total = 0; const pending = [root]; while (pending.length > 0) { const node = pending.pop(); if (node === null) continue; total += node.value; pending.push(node.next); } return total;} Keep a to-do list where you only add and remove the top task. You can handle one task at a time and add new tasks as they appear. An explicit stack does that for tree traversal.
- In real life: Keep every task in your head
- In JavaScript: Recursive calls remember pending work on the call stack
- In real life: Write tasks in a top-only list
- In JavaScript: An array stores pending nodes explicitly
- In real life: Take the top task, then add new top tasks
- In JavaScript: Pop one node, push its children or next pointer
- In real life: Read the list whenever you need
- In JavaScript: The explicit stack is visible program state
Where the analogy stops: A to-do list can still get huge. An explicit stack avoids call-stack overflow, but it still uses heap memory and needs sensible bounds.
| Technique | Where pending work lives | Use it when |
|---|---|---|
| Recursive traversal | Call stack remembers pending work | Simple for shallow trees; crashes when depth exceeds the host stack. |
| Explicit stack loop | A heap array remembers pending work | A little more code; handles very deep data because the JS call stack stays shallow. |
| Explicit queue loop | A heap array visits breadth-first | Useful for level-order UI work, but still avoid unbounded memory growth. |
| Proper tail call | Engine may reuse the current frame | Covered in the next lesson; most engines do not ship general PTC today. |
sum(node.next)on a linked list that can be 100000 nodes deep.- A
whileloop withconst pending = [root]for tree traversal. - Running the same probe with
node --stack-size=256and--stack-size=768. - A function with many parameters and locals recursing less deeply than a tiny function.
- Walking a Bengaluru category tree with an explicit stack of categories to visit.
- A recursive save function writes partial state and catches the eventual
RangeError.
Sort each card by whether it is an overflow risk, a safer pattern, or a host-dependent fact.
Where this shows up in real apps
Deep nesting appears in comment threads, category trees, CMS page trees, file explorers, menus, JSON imports, graph traversals, and route builders. A Bengaluru events site might fetch a comment thread where replies nest far deeper than the UI designer expected.
const thread = { text: "Bengaluru meetup", replies: [ { text: "Where in Indiranagar?", replies: [] }, { text: "Can I bring a friend?", replies: [ { text: "Yes, register both names.", replies: [] }, { text: "Metro is easier than parking.", replies: [] }, ] }, ],}; function countComments(root) { let count = 0; const pending = [root]; while (pending.length > 0) { const comment = pending.pop(); if (!comment) continue; count += 1; for (const reply of comment.replies) pending.push(reply); } return count;} console.log(countComments(thread));Line 14 stores the first comment to visit. Line 16 pops one comment. Line 19 pushes replies, so depth becomes heap data instead of call-stack depth. The example prints 5.
- Use recursion freely when depth is bounded and clear.
- Use an explicit stack or queue when depth comes from user data, API data, or imported files.
- Keep a guard for cycles or runaway graph traversal; stack overflow is not your cycle detector.
- Measure first if you are changing code for performance. The safety reason is depth, not folklore.
- Link to Tail calls for proper tail calls and trampolines instead of teaching them here.
Common misconceptions
- “The limit is exactly the same everywhere.” It varies by host, stack reservation, frame size, optimization state, and device.
- “Stack overflow means the heap is full.” Stack and heap are different resources. An explicit stack uses heap memory to avoid nesting calls.
- “Catching `RangeError` is a good loop condition.” Recovery code runs after partial work already happened and while the stack was near exhaustion.
- “All recursion is bad.” Recursion is fine for shallow, bounded structures. The risk is unbounded depth.
- “Tail calls solve this everywhere.” Proper tail calls and trampolines are the next lesson. Do not assume general PTC in every engine.
let writes = 0;function riskySave() { writes += 1; return riskySave();} try { riskySave();} catch (error) { console.log(error instanceof RangeError); console.log(writes > 0);}This snippet usually logs true twice in V8: the error is a RangeError, and writes is already greater than zero. Catching the error did not roll back line 3.
| Idea | What it means | Do not confuse it with |
|---|---|---|
| Stack overflow | A host ran out of call-stack space for nested calls. | Not a heap memory leak and not proof the recursive algorithm is mathematically wrong. |
| Recursion depth | How many calls happened before the host threw. | Not portable across engines, flags, devices, or function shapes. |
| Catching the error | Possible in many cases. | Not a safe control-flow strategy because partial work may already have happened. |
| Iteration fix | Move pending work into your own array or queue. | Not automatically faster; it is safer for unbounded depth and often easier to resume. |
Practice exercises
What does the safe depth demo print?
function countUntilLimit(limit) {
let depth = 0;
function dive() {
depth += 1;
if (depth === limit) return depth;
return dive();
}
return dive();
}
console.log(countUntilLimit(3));It prints 3. Line 4 increments the counter for each call, and line 5 returns when depth === limit.
What error constructor does the Node probe catch for stack overflow?
Node 22 / V8 12.4 catches a RangeError with message Maximum call stack size exceeded.
What short message does Firefox use for deep recursion?
MDN documents Firefox/SpiderMonkey as InternalError: too much recursion.
Name the failure you expect from this program.
function makeDeepList(depth) {
let node = null;
for (let i = 0; i < depth; i += 1) {
node = { value: 1, next: node };
}
return node;
}
function sumRecursive(node) {
if (node === null) return 0;
return node.value + sumRecursive(node.next);
}
const deepList = makeDeepList(100000);
console.log(sumRecursive(deepList));The bug is unbounded recursion over a 100000-node list. The host stack overflows before the recursive calls can unwind.
A category tree on a shopping site can be nested by sellers. What should hold the pending categories while you traverse it?
const list = { value: 1, next: { value: 2, next: { value: 3, next: null } } };
function sumWithExplicitStack(root) {
let total = 0;
const pending = [root];
while (pending.length > 0) {
const node = pending.pop();
if (node === null) continue;
total += node.value;
pending.push(node.next);
}
return total;
}
console.log(sumWithExplicitStack(list));Use an explicit stack or queue, often just an array named pending, so data depth no longer becomes call-stack depth.
With the same host stack, which probe reaches fewer recursive calls: a tiny frame or a large frame?
The large-frame function reaches fewer calls. The stack budget is bytes, so bigger frames fit fewer times.
Check your understanding
Answer by separating JavaScript semantics from host limits, then decide where the pending work lives.
Question 1 of 8What does 'maximum call stack size' mean in practice?
Choose an answer to see the explanation.
Question 2 of 8What does this guarded depth demo print?
Read the code, then predictfunction countUntilLimit(limit) { let depth = 0; function dive() { depth += 1; if (depth === limit) return depth; return dive(); } return dive(); } console.log(countUntilLimit(3));Choose an answer to see the explanation.
Question 3 of 8In Node 22 / V8 12.4, which stable facts does the lesson's child process prove?
Choose an answer to see the explanation.
Question 4 of 8Which browser-family error wording does MDN document for Firefox?
Choose an answer to see the explanation.
Question 5 of 8Why does the large-frame probe recurse less deeply than the tiny one in the same Node process?
Choose an answer to see the explanation.
Question 6 of 8What does this iterative linked-list sum print?
Read the code, then predictconst list = { value: 1, next: { value: 2, next: { value: 3, next: null } } }; function sumWithExplicitStack(root) { let total = 0; const pending = [root]; while (pending.length > 0) { const node = pending.pop(); if (node === null) continue; total += node.value; pending.push(node.next); } return total; } console.log(sumWithExplicitStack(list));Choose an answer to see the explanation.
Question 7 of 8Why is catching a stack-overflow
RangeErrora poor control-flow strategy?Choose an answer to see the explanation.
Question 8 of 8What should you do for a tree, comment thread, or category list that can be very deep?
Choose an answer to see the explanation.
Key takeaways
- Deep recursion crashes because unfinished calls consume finite stack space.
- The limit is host-dependent and frame-size-dependent; never assert one exact portable depth.
- V8 and JavaScriptCore report stack overflow as
RangeError; Firefox documentsInternalError: too much recursion. - Measure stack depth only as a local diagnostic, and catch overflow only to report it, not as normal control flow.
- For unbounded trees, lists, and comment threads, move pending work into an explicit stack or queue.
One-line summary.
Do not let unbounded data depth become unbounded call-stack depth.
Next: Tail calls explains proper tail calls and trampolines. We linked to it here; it teaches the tail-call details there.