Loading…

ES6 promised tail call optimization — but V8 and Firefox never delivered. Here's what actually happens in Node.js when you write tail-recursive code, and the patterns (trampolining, iteration, generators) that actually work everywhere.
💡 TL;DR: ES6 added tail call optimization (TCO) to the spec, but only Safari implements it. V8 (Node.js, Chrome) and SpiderMonkey (Firefox) don't — and never will. Here's what really happens when you write "tail-recursive" JavaScript, and what to do instead.
Functional programmers love tail call optimization (TCO). The promise is elegant: write recursive algorithms without blowing the call stack. ES2015 added TCO to the JavaScript specification. Books were written about it. Blog posts celebrated it.
There's just one problem: almost no JavaScript engine actually implements it.
If you've been writing tail-recursive code in Node.js expecting it to avoid stack overflows, you've been living on borrowed time. This post explains exactly what TCO is, why the JS ecosystem failed to deliver on it, what actually happens in V8 when you think you're using TCO, and the reliable alternatives that actually work everywhere.
A tail call is when the very last action a function performs before returning is to call another function. The key insight: since the calling function has nothing left to do, its stack frame is no longer needed.
With TCO, the engine can reuse the existing stack frame instead of allocating a new one, making recursion as memory-efficient as iteration.
// Without TCO — accumulates stack frames:
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1); // NOT a tail call — must multiply after returning
}// With TCO — tail call position:
function factorial(n, acc = 1) {
if (n <= 1) return acc;
return factorial(n - 1, n * acc); // IS a tail call — nothing left to do after this
}
The second version is in tail position: after calling factorial(n - 1, n * acc), the function returns that result directly with no further computation. The engine could, in theory, recycle the stack frame.
// Non-tail-recursive (grows the stack):
factorial(5)
factorial(4)
factorial(3)
factorial(2)
factorial(1) → 1
→ 2 * 1 = 2
→ 3 * 2 = 6
→ 4 * 6 = 24
→ 5 * 24 = 120
// Tail-recursive with TCO (constant stack space):
factorial(5, 1)
→ factorial(4, 5) ← same frame reused
→ factorial(3, 20) ← same frame reused
→ factorial(2, 60) ← same frame reused
→ factorial(1, 120) ← same frame reused
→ 120
ES2015 (ES6) formally specified Proper Tail Calls (PTC) as a required language feature. So what went wrong?
The V8 team implemented TCO experimentally behind a flag. They quickly ran into two fundamental problems:
Problem 1: Debugging becomes nightmarish. When stack frames are eliminated, stack traces lose information. Developers see a call stack that doesn't reflect how the code was written, making it nearly impossible to debug recursive functions.
Problem 2: Performance regressions in real-world code. The overhead of determining whether a call is in tail position, combined with the added complexity in the JIT pipeline, caused measurable regressions in non-recursive code. V8 ultimately removed the implementation.
Mozilla's SpiderMonkey (Firefox) evaluated TCO and declined to implement it, citing similar concerns around developer experience and the complexity cost.
Safari's JavaScriptCore is the only major JavaScript engine that implements Proper Tail Calls. If your app runs exclusively on Safari — good news. For everyone else, TCO is a fiction.
Let's see what really happens when you run "tail-recursive" code in Node.js:
function countDown(n) {
if (n === 0) return 'done';
return countDown(n - 1); // This looks like a tail call
}
countDown(100000); // 💥 RangeError: Maximum call stack size exceeded
V8 creates a new stack frame for every single call, exactly as if there were no tail call at all. The call stack grows until it hits Node.js's default stack size limit (typically ~10,000–15,000 frames depending on the platform and available memory).
Frame 15,000: countDown(0) ← stack overflow before reaching here
Frame 14,999: countDown(1)
Frame 14,998: countDown(2)
...
Frame 2: countDown(99,998)
Frame 1: countDown(99,999)
Frame 0: countDown(100,000) ← entry pointSince we can't rely on TCO, here are the battle-tested patterns to safely handle deep recursion in JavaScript.
A trampoline is a higher-order function that wraps a recursive function and runs it iteratively. Instead of calling itself, the recursive function returns a thunk (a zero-argument function), and the trampoline keeps calling it until it gets a non-function value.
// The trampoline runner
function trampoline(fn) {
return function(...args) {
let result = fn(...args);
while (typeof result === 'function') {
result = result();
}
return result;
};
}
// The trampolined function — returns a thunk instead of calling directly
function _factorial(n, acc = 1) {
if (n <= 1) return acc;
return () => _factorial(n - 1, n * acc); // Return a thunk, don't call directly
}
const factorial = trampoline(_factorial);
console.log(factorial(100000)); // ✅ Works perfectly — O(1) stack space
Why it works: The call stack never grows beyond a single frame because each call returns a value (a thunk) immediately. The iteration happens in the while loop, not the call stack.
The most straightforward and performant approach: just use a loop. Recursion is a mental model; iteration is how hardware actually executes.
// Recursive version (stack unsafe for large n):
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
// Iterative version (always safe):
function factorial(n) {
let result = 1;
for (let i = 2; i <= n; i++) {
result *= i;
}
return result;
}
// Tree traversal — recursive version:
function sumTree(node) {
if (!node) return 0;
return node.value + sumTree(node.left) + sumTree(node.right);
}
// Iterative version using an explicit stack:
function sumTree(root) {
if (!root) return 0;
const stack = [root];
let sum = 0;
while (stack.length > 0) {
const node = stack.pop();
sum += node.value;
if (node.right) stack.push(node.right);
if (node.left) stack.push(node.left);
}
return sum;
}
Generators give you explicit control over execution, allowing you to simulate recursion without growing the call stack:
function* lazyRange(start, end) {
if (start > end) return;
yield start;
yield* lazyRange(start + 1, end); // Still recursive, but lazily evaluated
}
// For larger depths, use an iterative generator:
function* range(start, end) {
for (let i = start; i <= end; i++) {
yield i;
}
}
// Consume safely:
for (const n of range(1, 1000000)) {
// process n
}
As a last resort, you can increase Node.js's stack size via the --stack-size flag:
node --stack-size=65536 app.js⚠️ Warning: This is a band-aid, not a fix. It delays the stack overflow but doesn't eliminate it. Use trampolining or iteration instead for production code.
ApproachBest ForReadabilityPerformanceStack Safe?TrampoliningPreserving recursive style✅ Good✅ Good✅ YesIterative rewriteMaximum performance⚠️ Sometimes verbose🚀 Best✅ YesGeneratorLazy sequences✅ Good✅ Good⚠️ DependsIncrease stack sizeQuick prototyping✅ No change needed✅ Good❌ NoRely on TCOSafari-only apps✅ Natural✅ Good⚠️ Safari only
🧠 Don't assume your JS engine optimizes tail calls. Verify — or use a runtime-safe alternative.
TCO is in the ES6 spec but only Safari's JavaScriptCore implements it
V8 and SpiderMonkey explicitly rejected TCO due to debugging and performance concerns
Tail-recursive code in Node.js will still blow the stack at ~10,000–15,000 frames
Trampolining is the most idiomatic functional-style solution — it's safe and readable
Iterative rewriting is the highest-performance solution for production
Generators work well for lazy sequences and co-routine-style flows