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 limitExplain why every call that hasn't finished uses stack space, and why that space has a limit.
- 02Read stack overflow errorsRecognize the
RangeErrorfrom Chrome, Node, and Safari, and Firefox'sInternalError: too much recursion. - 03Avoid the crashCount how deep calls go without trusting one exact number, and turn deep recursion into a loop with an explicit stack.
Why recursion can crash
A recursive function is a function that calls itself. It can call itself directly, or through other functions. The Recursion and Advanced recursion lessons show when this is useful. This lesson is about a limit. If the calls go too deep, the program crashes.
When a function calls another function, the first one is not finished yet. It waits. JavaScript keeps a small record for each waiting call. This record is called a stack frame. It holds the call's values and the place to go back to when the call ends. All the frames sit on the call stack, one on top of another. The Stack frames & calling conventions lesson shows what a frame holds.
A stack overflow happens when the waiting calls need more stack space than there is. In JavaScript, it usually comes from a recursive function that calls itself too many times, or never stops. The program throws an error before the calls can finish.
dive() call waits for the next one, so the calls pile up. When the stack space is full, the next call has no room. That is a stack overflow.The amount of stack space is set by the host. The host is the program that runs your JavaScript, such as a browser or Node. Inside each host is an engine: the part that runs the code. Chrome and Node use the V8 engine. Firefox uses SpiderMonkey. Safari uses JavaScriptCore.
The official rulebook for JavaScript is called ECMAScript. It says how calls and returns work. It does not say how much stack space there must be. It also does not say which error to throw when the space runs out. Each engine decides that.
Step through a tiny recursion that stops after three calls. Watch the counter grow with each call. Code without a guard keeps going until the stack is full.
script
function countUntilLimit(limit) { let depth = 0; function dive() { depth += 1; if (depth === limit) return depth; return dive(); } return dive();} The step-through above is tiny and safe. It stops after three calls. Line 2 sets a counter, depth, to 0. Line 4 adds 1 to it. Line 6 calls dive() again, so one more call is now waiting. Line 5 is a guard: a check that stops the calls. Without a guard, the calls keep piling up until the host has no room for another frame.
The limit is space, not a number of calls
The error message says "Maximum call stack size exceeded". That sounds like a fixed number of calls. It isn't. Most engines limit the space on the stack, counted in bytes. A byte is a tiny unit of memory.
- 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 has a fixed height. Small plates make a tall stack. Big plates reach the top sooner.
- In real life: The shelf has a fixed height
- In JavaScript: The host gives a fixed amount of stack space
- In real life: Each plate takes up some height
- In JavaScript: Each waiting call needs a frame
- In real life: Big plates mean fewer plates fit
- In JavaScript: A call with more parameters and variables has a bigger frame, so fewer calls fit
- In real life: Another shelf can have a different height
- In JavaScript: Another browser, device, or Node setting can give a different amount of space
The table answers four questions about the limit. Read each row from left to right.
| Question | Answer | A common mistake |
|---|---|---|
| What has a limit? | The amount of stack space, in bytes. The host sets it. | Thinking JavaScript sets a fixed number of calls. |
| Why does the depth change? | Each frame holds the place to go back to, the parameters, the local variables, and notes for the engine. Bigger frames fit fewer times. | Thinking every call takes the same space. |
| What makes hosts differ? | The engine, the device, the setup of the browser or a worker, and Node settings. | Thinking a number you measured is true everywhere. It is only true for that host, at that time. |
| How do I change it in Node? | node --stack-size=<kB> sets how much stack space V8 gets. | Writing tests that expect one exact depth. Compare numbers instead. |
In Node, you can change the amount of stack space with a setting: node --stack-size=<kB>. The number is in kilobytes (kB). A kilobyte is about a thousand bytes. This setting is good for experiments. It is not a real fix for code that recurses as deep as the data a user sends.
Chrome says RangeError, Firefox says InternalError
Each engine reports a stack overflow in its own way:
- In Chrome and Node (the V8 engine), the error is a
RangeError. Its message isMaximum call stack size exceeded. - In Safari (JavaScriptCore), MDN shows the same
RangeErrormessage, with a full stop at the end. - In Firefox (SpiderMonkey), MDN shows
InternalError: too much recursion.
RangeError. Firefox calls it an InternalError.InternalError is not one of the standard errors in the ECMAScript rulebook. Only Firefox has it. So code that should work in every browser must not expect InternalError to exist.
The table lists each engine, the type of error it throws, and the message you will likely see.
| Engine | Error type | Message you will likely see |
|---|---|---|
| V8 (Chrome, Node) | RangeError | Maximum call stack size exceeded |
| JavaScriptCore (Safari) | RangeError | Maximum call stack size exceeded. MDN shows it with a full stop at the end. |
| SpiderMonkey (Firefox) | InternalError | too much recursion. InternalError is not a standard error in the ECMAScript rulebook. |
| The ECMAScript rulebook | It doesn't name one. | The rulebook says how calls work. Each engine decides how to report a full stack. |
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(),}));This code has two functions. Each one calls itself until the stack is full. Line 4 and line 18 add 1 to a counter on every call. When the error comes, line 10 and line 25 save the counter, the error's name and message, and whether it is a RangeError. Line 29 prints all of it as text, so the tests can read it.
The lesson's test runs this code in Node 22, which uses V8 version 12.4. It runs it twice, with two different --stack-size settings. It does not check exact numbers. It checks three facts:
- A smaller
--stack-sizegives fewer calls. - The function with large frames makes fewer calls than the function with small frames.
- The error is a
RangeErrorwith the messageMaximum call stack size exceeded.
The browser messages in the table come from the MDN page InternalError: too much recursion. MDN is a well-known reference site for web developers. The facts about Node and V8 are checked by the lesson's test.
Measure the depth, but don't trust one number
You can count how many calls fit before the error. This count is the depth. A depth count helps you understand a problem. It tells you what happened in this host, for this function, right now. Don't copy the number into your app as a fixed limit.
The playground below runs two real tests in your browser:
- The small-frame function only adds 1 to a counter. Then it calls itself.
- The large-frame function takes 26 parameters and adds them up. Then it calls itself.
Expect two different numbers. Don't expect any 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 }; }}Nothing counted yet. Press the button once. Reset clears the result.
Press Measure my browser. It counts calls until your browser throws the error, for a small frame and for a large one.
Line 4 adds 1 to the counter in the small function. Line 18 adds 1 to a separate counter in the large function. Both functions use try...catch. It runs the code in try. If that code throws an error, JavaScript jumps to the catch part instead of crashing. So the page can show the numbers, and the lesson keeps working.
Say your browser reports 12000 calls today. Another browser, phone, or browser version can report a different number. So can a worker, a script that runs in the background. Use the number to understand the risk. Don't use it as a limit in real code.
Bigger frames mean fewer calls
The Node test gives both functions the same amount of stack space. The small function has almost nothing in its frame. The large function has 26 parameters and a local variable. Each large call uses more bytes, so the large function runs out of space sooner.
So a claim like "my browser handles about N calls" is not complete. You also need to know which function made N calls, and in which host.
The test does not prove that Node allows a certain number of calls. It proves two comparisons:
- Less stack space means fewer calls.
- With the same space, bigger frames mean fewer calls.
Use a loop when the depth has no limit
Recursion is often the clearest way to walk through data shaped like a tree, where each item can hold more items. It is safe when you know the depth stays small. It is risky when the depth comes from outside your code: from users, from a server, or from imported files.
The next example uses a linked list. A linked list is a chain of objects. Each object has a value and a next property. The next property points to the next object in the chain. The last object has next: null.
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 list of 100000 objects. Line 15 calls the recursive sum. Line 11 is the problem. It calls sumRecursive again for the next object, before the current call can finish. So every object in the list adds one more waiting call.
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));This version uses a loop instead of more calls:
- Line 11 makes
pending, an array of objects still to visit. - Line 13 takes the last object off the array with
pop(). - Line 15 adds its value to the total.
- Line 16 puts the next object on the array with
push().
Only one function call is waiting the whole time, and the program prints 100000. The array lives in the heap. The heap is the large area of memory where JavaScript keeps objects and arrays. It has much more room than the call stack. An array that you add to and take from at the same end is also called a stack. Because your own code manages it, it is called an explicit stack.
pending array instead.Step through the loop version. The pending array is your to-do list. It lives in the heap, and only one call waits on the stack.
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;} Picture a to-do list where you only add and remove tasks at the top. You take the top task and do it. If it brings new tasks, you write them on top. You never have to keep tasks in your head.
- In real life: Keep every task in your head
- In JavaScript: Recursive calls keep the waiting work on the call stack
- In real life: Write the tasks on a list
- In JavaScript: An array holds the objects still to visit
- In real life: Take the top task, then write any new tasks on top
- In JavaScript: Pop one object, then push the next one
- In real life: You can read the list at any time
- In JavaScript: Your code can look at the array at any time
The table compares four ways to walk through deep data. Read each row from left to right.
| Way | Where the waiting work is kept | When to use it |
|---|---|---|
| Recursion | On the call stack, as waiting calls. | When the data is shallow. It crashes when the depth is more than the stack can hold. |
| Loop with an explicit stack | In an array in the heap. You take from the end. | When the data can be very deep. It needs a little more code, but only one call waits. |
| Loop with a queue | In an array in the heap. You take from the front, so you visit one level at a time. | When you want to visit items level by level. Watch that the array doesn't grow without end. |
| Proper tail call | The engine may reuse the current frame. | The next lesson covers it. Most engines don't support it today. |
sum(node.next)on a linked list that can be 100000 objects long.- A
whileloop withconst pending = [root]to walk through a tree. - Running the same test with
node --stack-size=256and with--stack-size=768. - A function with many parameters and variables makes fewer calls than a tiny function.
- Walking through a Bengaluru shop's category tree with an explicit stack of categories to visit.
- A recursive save function saves part of the data, then catches the
RangeErrorat the end.
Put each card in a group: a stack overflow risk, a safer way, or a fact that depends on the host.
Where this shows up in real apps
Data nested many levels deep shows up in many places:
- comment threads, with replies to replies
- category trees and menus in a shop
- page trees in a content management system (CMS), the tool a team uses to edit its website
- file explorers, with folders inside folders
- JSON files that you import
- maps of links between pages
For example, a Bengaluru events site loads a comment thread. The replies can nest much deeper than the 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 puts the first comment in pending. Line 16 takes one comment off. Line 18 counts it. Line 19 puts all its replies on pending. So the deep replies wait in the array, not on the call stack. The example prints 5.
- Use recursion when you know the depth stays small.
- Use a loop with an explicit stack or a queue when the depth comes from users, a server, or imported files. A queue is an array where you add at the end and take from the front.
- Some data can point back to itself in a circle. Check for that in your own code. Don't wait for a stack overflow to stop it.
- If you change code to make it faster, measure first. The reason for a loop here is safety with deep data, not speed.
- The Tail calls lesson covers tail calls and trampolines. This lesson doesn't.
Common misconceptions
- "The limit is the same everywhere." It changes with the host, the stack space setting, the frame size, the device, and how the engine has sped up the code.
- "A stack overflow means all memory is full." The call stack and the heap are different areas of memory. An explicit stack uses the heap, so the calls don't pile up.
- "I can catch the
RangeErrorand use it to stop a loop." By the time you catch it, some work has already happened. The stack was almost full when the error was thrown. - "All recursion is bad." Recursion is fine when the depth is small and has a limit. The risk is depth with no limit.
- "Tail calls fix this everywhere." Tail calls and trampolines are the next lesson. Most engines don't support proper tail calls.
let writes = 0;function riskySave() { writes += 1; return riskySave();} try { riskySave();} catch (error) { console.log(error instanceof RangeError); console.log(writes > 0);}In V8, this code prints true twice. The first true says the error is a RangeError. The second says writes is already more than 0. Catching the error did not undo line 3.
The table lists four ideas that are easy to mix up. Each row says what the idea means and what it is not.
| Idea | What it means | What it is not |
|---|---|---|
| Stack overflow | The host ran out of stack space for waiting calls. | It is not a heap memory leak. It also doesn't mean the idea of the function is wrong. |
| Recursion depth | How many calls happened before the error. | It is not the same in every engine, device, setting, or function. |
| Catching the error | You can often catch it with try...catch. | It is not a safe way to stop your code, because some work has already happened. |
| The loop fix | Keep the waiting work in your own array or queue. | It is not always faster. It is safer when the depth has no limit. |
Practice exercises
What does this code 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 adds 1 to the counter on each call. Line 5 returns when depth === limit.
What type of error does the Node depth test catch when the stack is full?
Node 22, with V8 12.4, throws a RangeError with the message Maximum call stack size exceeded.
What short message does Firefox show when recursion goes too deep?
MDN shows Firefox's message as InternalError: too much recursion.
What goes wrong when this code runs?
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 11 calls itself once for every object in a list 100000 long. The stack runs out of space before the calls can finish. That is a stack overflow.
On a shopping site, sellers can put categories inside categories, many levels deep. You walk through them with a loop. What should hold the categories you still have to visit?
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 a queue. Often it is just an array called pending. Then deep data no longer means deep calls.
Both functions get the same stack space. Which one makes fewer calls: the one with a small frame, or the one with a large frame?
The large frame makes fewer calls. The limit is space in bytes, so bigger frames fit fewer times.
Check your understanding
For each question, ask two things. Is this a JavaScript rule, or something the host decides? And where is the waiting work kept?
Question 1 of 8What does 'maximum call stack size' really mean?
Choose an answer to see the explanation.
Question 2 of 8What does this counter with a guard 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, with V8 12.4, which fact does the lesson's test check?
Choose an answer to see the explanation.
Question 4 of 8Which error message does MDN show for Firefox?
Choose an answer to see the explanation.
Question 5 of 8In the same Node program, why does the large-frame function make fewer calls than the tiny one?
Choose an answer to see the explanation.
Question 6 of 8What does this loop version of the 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 bad way to stop your code?Choose an answer to see the explanation.
Question 8 of 8What should you do with a tree, comment thread, or category list that can be very deep?
Choose an answer to see the explanation.
Key takeaways
- Each waiting call uses some stack space. The space has a limit, so very deep recursion crashes.
- The limit depends on the host and on the frame size. Never count on one exact depth.
- Chrome, Node, and Safari throw a
RangeError. Firefox throwsInternalError: too much recursion. - Measure the depth only to understand a problem. Catch the error only to report it, not to stop a loop.
- For deep trees, lists, and comment threads, keep the waiting work in an explicit stack or a queue.
One-line summary.
Don't let deep data turn into deep calls.
Next: Tail calls explains proper tail calls and trampolines.