What happened
Your Node.js script crashed with something like this:
RangeError: Maximum call stack size exceeded
at Object.<anonymous> (/app/utils.js:12:5)
at Object.<anonymous> (/app/utils.js:12:5)
at Object.<anonymous> (/app/utils.js:12:5)
...
Notice how the same line repeats hundreds of times. That's your clue โ a function is calling itself (or calling another function that calls it back) without ever stopping.
Understanding the call stack
Every function call pushes a frame onto V8's call stack. V8 caps that stack at roughly 10,000โ15,000 frames, depending on the platform and available memory. Go deeper than that limit and V8 throws RangeError: Maximum call stack size exceeded rather than letting memory spiral out of control.
Three things cause this most often:
- A recursive function with a missing or broken base case
- Two functions calling each other in a cycle (mutual recursion)
- Calling
JSON.stringifyon an object that references itself
Debug process
Step 1 โ Read the stack trace
By default Node.js only shows 10 frames. Bump that up so you can see the repeating pattern:
node --stack-trace-limit=50 index.js
Same function calling itself? Infinite recursion. Alternating A โ B โ A โ B? Mutual recursion. Either way, the trace tells you exactly which file and line to open first.
Step 2 โ Add a depth counter
Before touching any logic, drop a counter into the suspicious function:
function processNode(node, depth = 0) {
if (depth > 100) {
console.log('Depth exceeded, node:', JSON.stringify(node, null, 2));
throw new Error('Recursion depth guard triggered');
}
// ... rest of function
return processNode(node.child, depth + 1);
}
Run it. The logged node value will show you the exact data that triggers runaway recursion โ far faster than staring at the code cold.
Step 3 โ Check for circular references
Did the error originate inside JSON.stringify? Circular reference. Classic example:
const a = { name: 'a' };
const b = { name: 'b', ref: a };
a.ref = b; // circular!
JSON.stringify(a); // => RangeError: Maximum call stack size exceeded
Detect it before serializing:
function hasCircular(obj) {
const seen = new WeakSet();
function detect(o) {
if (typeof o !== 'object' || o === null) return false;
if (seen.has(o)) return true;
seen.add(o);
return Object.values(o).some(detect);
}
return detect(obj);
}
console.log(hasCircular(a)); // true
Solutions
Fix 1 โ Add or fix the base case
This is the most common root cause by far. Every recursive function needs a condition that stops it cold:
// BROKEN โ recurses forever
function factorial(n) {
return n * factorial(n - 1);
}
// FIXED
function factorial(n) {
if (n <= 1) return 1; // <-- base case
return n * factorial(n - 1);
}
Simple, but easy to miss when the base case logic depends on data shape rather than a numeric counter.
Fix 2 โ Convert deep recursion to iteration
Trees and linked lists can be arbitrarily deep in production. Rewrite with a loop and an explicit stack โ it handles any depth without touching V8's limit:
// Recursive (blows up on trees deeper than ~10,000 nodes)
function sumTree(node) {
if (!node) return 0;
return node.value + sumTree(node.left) + sumTree(node.right);
}
// Iterative (safe for any depth)
function sumTree(root) {
const stack = [root];
let total = 0;
while (stack.length) {
const node = stack.pop();
if (!node) continue;
total += node.value;
stack.push(node.left, node.right);
}
return total;
}
Same result, zero stack risk.
Fix 3 โ Use trampolining for tail recursion
Refactoring to a loop sometimes mangles the algorithm's readability. Trampolining keeps the recursive style but sidesteps stack growth entirely:
function trampoline(fn) {
return function(...args) {
let result = fn(...args);
while (typeof result === 'function') {
result = result();
}
return result;
};
}
const factorial = trampoline(function fact(n, acc = 1) {
if (n <= 1) return acc;
return () => fact(n - 1, n * acc); // thunk instead of direct call
});
console.log(factorial(50000)); // works fine
The key is returning a function (thunk) instead of calling recursively. The trampoline runner unwinds it in a plain loop.
Fix 4 โ Break async recursion with setImmediate
Async functions look safe from stack overflows. They're not. Each await resumes synchronously on the same stack frame if the promise is already resolved. Fix: explicitly yield to the event loop:
async function processItems(items, index = 0) {
if (index >= items.length) return;
await processOne(items[index]);
// yield to the event loop โ clears the stack AND prevents blocking
await new Promise(resolve => setImmediate(resolve));
return processItems(items, index + 1);
}
Fix 5 โ Serialize circular objects safely
Two options. Roll your own replacer, or reach for the flatted package:
// Option A: custom replacer (no dependencies)
function safeStringify(obj) {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
}
return value;
});
}
// Option B: flatted (preserves circular structure on parse)
// npm install flatted
import { stringify, parse } from 'flatted';
const serialized = stringify(circularObj);
Use Option A when you just need safe logging. Use Option B when you need to round-trip the data (serialize โ deserialize with structure intact).
Verification
Apply the fix, then confirm it actually holds:
- Run the script โ no
RangeErrorthis time. - Test with edge cases: empty input, single-element input, a tree 100,000 nodes deep.
- For iterative rewrites, cross-check output against the recursive version on small inputs first.
- Remove the depth guard โ or keep it with a sensible limit and a clear error message if it fires in production.
# Quick smoke test
node -e "const { sumTree } = require('./tree'); console.log(sumTree(buildDeepTree(100000)))"
Lessons learned
- Repeating frames in a stack trace mean one thing: recursion without a working base case. Read the trace before touching any code.
- Never trust that user-supplied data is shallow. Trees, nested configs, JSON from an API โ any of them can be arbitrarily deep. Use iteration.
JSON.stringifycrashing almost always means circular references. AWeakSetreplacer costs 5 lines. Write it once, reuse it everywhere.- Async recursion isn't stack-safe by default. If you're looping over thousands of items recursively, add a
setImmediateyield or rewrite as a plainforloop withawait.

