cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Explain the limitDescribe why every unfinished function call uses stack space and why hosts enforce a finite maximum call stack size.
  • 02
    Read stack overflow errorsRecognize V8 and JavaScriptCore RangeError messages, Firefox's InternalError: too much recursion, and what the spec leaves to engines.
  • 03
    Avoid 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.

Definition

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 recursion depth counter
Step 0 of 10Ready
Your turn: follow the blue line

Step through a tiny guarded recursion before measuring your real browser. The counter shows why unbounded recursion eventually reaches the host's stack limit.

Running in
  1. script
Next: line 11
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
function countUntilLimit(limit) {  let depth = 0;  function dive() {    depth += 1;    if (depth === limit) return depth;    return dive();  }  return dive();} 
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Real-life analogyA stack of plates on a kitchen shelf

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.

What the maximum stack size depends on
QuestionUseful answerCommon mistake
What is limitedBytes of native stack reserved by the hostNot a JavaScript-specified number of calls.
Why depth variesEach frame needs return information, parameters, locals, temporaries, and engine bookkeepingBigger frames fit fewer times in the same stack budget.
Where hosts differBrowser engine, device, OS thread stack, worker stack, and Node flagsYour measured number is only for that environment at that moment.
Node examplenode --stack-size=<kB> changes V8's stack reservationUse 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.

Stack overflow error names by engine family
Engine familyError typeMessage you are likely to see
V8 / Chrome / NodeRangeErrorMaximum call stack size exceeded
JavaScriptCore / SafariRangeErrorMaximum call stack size exceeded. with a trailing period in MDN's browser table
SpiderMonkey / FirefoxInternalErrortoo much recursion; InternalError is not an ECMAScript standard error constructor
ECMAScript specNo required stack-overflow error typeThe language defines recursive calls, but each implementation chooses how to report stack exhaustion
Node-only V8 stack probe used by the testsJavaScript
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(),}));
Verified runtime fact

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.

Measure your browser's stack depth
Browser-side depth probes used by the playgroundJavaScript
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 };  }}
Your browser, right nownot run

No measurement yet. Press the button once; Reset clears the local result.

Step 0 of 1ready

Press Measure my browser to count until the real browser throws, then compare a tiny frame with a larger one.

This runs real recursive probes in your browser tab. It reports local measurements only; engine, device, worker stack, and function shape can change the numbers.

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.

Do not ship the number

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 relation is the lesson

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.

Recursive linked-list sum that overflows at depth 100000Pop out in the code editor (opens in a new tab)JavaScript
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.

Same list, explicit stack loopPop out in the code editor (opens in a new tab)JavaScript
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.

Step through the explicit-stack loop
Step 0 of 14Ready
Your turn: follow the blue line

Replay the iterative version. The array named pending is your to-do list; the call stack stays shallow while the heap stores pending work.

Running in
  1. script
Next: line 17
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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;} 
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Real-life analogyA to-do list you can only add to the top of

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.

Recursive traversal versus explicit work lists
TechniqueWhere pending work livesUse it when
Recursive traversalCall stack remembers pending workSimple for shallow trees; crashes when depth exceeds the host stack.
Explicit stack loopA heap array remembers pending workA little more code; handles very deep data because the JS call stack stays shallow.
Explicit queue loopA heap array visits breadth-firstUseful for level-order UI work, but still avoid unbounded memory growth.
Proper tail callEngine may reuse the current frameCovered in the next lesson; most engines do not ship general PTC today.
Overflow risk or safer pattern?
  • sum(node.next) on a linked list that can be 100000 nodes deep.
  • A while loop with const pending = [root] for tree traversal.
  • Running the same probe with node --stack-size=256 and --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.
Try it yourself
0 of 6 correct

Sort each card by whether it is an overflow risk, a safer pattern, or a host-dependent fact.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

Count a nested Bengaluru comment thread with an explicit stackPop out in the code editor (opens in a new tab)JavaScript
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.
Catching overflow does not undo partial workPop out in the code editor (opens in a new tab)JavaScript
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.

Similar ideas that are easy to mix up
IdeaWhat it meansDo not confuse it with
Stack overflowA 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 depthHow many calls happened before the host threw.Not portable across engines, flags, devices, or function shapes.
Catching the errorPossible in many cases.Not a safe control-flow strategy because partial work may already have happened.
Iteration fixMove pending work into your own array or queue.Not automatically faster; it is safer for unbounded depth and often easier to resume.

Practice exercises

Exercise 1 · Warm-upPredict the guarded counter

What does the safe depth demo print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
function countUntilLimit(limit) {
  let depth = 0;
  function dive() {
    depth += 1;
    if (depth === limit) return depth;
    return dive();
  }
  return dive();
}
console.log(countUntilLimit(3));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upName the V8 error

    What error constructor does the Node probe catch for stack overflow?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeRemember Firefox's wording

      What short message does Firefox use for deep recursion?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFind the bug in the linked-list sum

        Name the failure you expect from this program.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · PracticeApply the fix to a site feature

          A category tree on a shopping site can be nested by sellers. What should hold the pending categories while you traverse it?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeState the frame-size relation

            With the same host stack, which probe reaches fewer recursive calls: a tiny frame or a large frame?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              Answer by separating JavaScript semantics from host limits, then decide where the pending work lives.

              Stack overflow quiz · 8 questionsScore: first tries count
              1. Question 1 of 8What does 'maximum call stack size' mean in practice?

                Choose an answer to see the explanation.

              2. Question 2 of 8What does this guarded depth demo print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function 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.

              3. 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.

              4. Question 4 of 8Which browser-family error wording does MDN document for Firefox?

                Choose an answer to see the explanation.

              5. 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.

              6. Question 6 of 8What does this iterative linked-list sum print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                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));

                Choose an answer to see the explanation.

              7. Question 7 of 8Why is catching a stack-overflow RangeError a poor control-flow strategy?

                Choose an answer to see the explanation.

              8. 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 documents InternalError: 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.

              CompleteFrontend Clear concepts. Working examples.