π‘ TL;DR: ES2015 added proper tail calls (PTC, often called tail call optimization or TCO) to the spec. Only JavaScriptCore implements them, so they work in Safari and Bun and nowhere else. Chrome, Edge, Node.js and Deno (all V8) and Firefox don't, and never will. Here's the support table, what really happens when you write "tail-recursive" JavaScript, and what to do instead.
Proper tail call support by browser and runtime
| Environment | Engine | Proper tail calls | Notes |
|---|---|---|---|
| Safari (macOS, iOS, iPadOS) | JavaScriptCore | β Yes | Since Safari 10 (2016), strict mode only |
| Any browser on iPhone or iPad (Chrome, Firefox, Edge) | JavaScriptCore | β Yes | iOS browsers run on WebKit |
| Bun | JavaScriptCore | β Yes | Strict mode only; ES modules are always strict |
| Chrome, Edge, Opera (desktop and Android) | V8 | β No | Tried behind a flag in 2016, removed in 2017 |
| Node.js | V8 | β No | Was behind a flag in Node 6 and 7, removed in Node 8 |
| Deno | V8 | β No | Same engine as Chrome |
| Firefox (desktop and Android) | SpiderMonkey | β No | Never shipped |
Strict mode matters. Even in Safari and Bun, a call is only optimized in strict-mode code: a file with "use strict", an ES module, or a class body. In a sloppy-mode script, Safari grows the stack like every other engine.
That's why MDN's compatibility data and the ECMAScript compatibility table list proper tail calls as Safari-only.
Introduction
Functional programmers love tail call optimization. The promise is elegant: write recursive algorithms without blowing the call stack. ES2015 added it 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 what TCO is, why the JS ecosystem never delivered it, what actually happens in V8, and the alternatives that work everywhere.
What is tail call optimization?
A tail call is when the very last thing a function does before returning is call another function. Since the calling function has nothing left to do, its stack frame is no longer needed.
With TCO, the engine reuses the existing stack frame instead of allocating a new one, which makes recursion as memory-efficient as a loop.
A classic example
// Not a tail call: must multiply after the recursive call returns
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
// A tail call: nothing left to do after the recursive call
function factorial(n, acc = 1) {
if (n <= 1) return acc;
return factorial(n - 1, n * acc);
}The second version is in tail position: after calling factorial(n - 1, n * acc), the function returns that result directly. The engine could, in theory, recycle the stack frame.
Stack behavior compared
// Not tail-recursive (the stack grows, then unwinds):
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 proper tail calls (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
β 120Why JavaScript engines don't implement it
ES2015 specified Proper Tail Calls (PTC) as a required language feature, not an optional optimization. So what went wrong?
V8: implemented, then removed
The V8 team implemented proper tail calls behind a flag in 2016 and ran into two problems:
Problem 1: debugging gets much harder. When frames are eliminated, they vanish from stack traces and DevTools. Developers see a call stack that doesn't match how the code was written, which makes recursive bugs very hard to trace.
Problem 2: it's not free. Keeping tail calls correct everywhere added complexity to the JIT pipeline, and V8 found no way to keep the lost frames without a cost. V8 removed the implementation in 2017.
SpiderMonkey: never shipped
Mozilla's SpiderMonkey (Firefox) evaluated proper tail calls and declined to implement them, citing similar concerns about developer experience and complexity.
JavaScriptCore: the one true believer
JavaScriptCore, the engine behind Safari and Bun, is the only major engine that implements proper tail calls. It keeps a shadow stack so its debugger can still show the eliminated frames. If your code only runs on Safari or Bun, good news. Everywhere else, TCO is a fiction.
What actually happens in Node.js
Here's what happens when you run "tail-recursive" code in Node.js:
function countDown(n) {
if (n === 0) return 'done';
return countDown(n - 1); // looks like a tail call
}
countDown(100000); // π₯ RangeError: Maximum call stack size exceededV8 creates a new stack frame for every call, exactly as if there were no tail call at all. The stack grows until it hits Node.js's limit, typically around 10,000 to 15,000 frames depending on the platform and how big each frame is.
Visualizing the stack growth
Frame 15,000: countDown(85,000) β stack overflow around here
...
Frame 2: countDown(99,998)
Frame 1: countDown(99,999)
Frame 0: countDown(100,000) β entry pointReliable alternatives that work everywhere
Since you can't rely on proper tail calls, here are the patterns that handle deep recursion safely in every engine.
Alternative 1: trampolining
A trampoline is a higher-order function that runs a recursive function in a loop. Instead of calling itself, the function returns a thunk (a zero-argument function), and the trampoline keeps calling thunks until it gets a value that isn't a function.
// The trampoline runner
function trampoline(fn) {
return function (...args) {
let result = fn(...args);
while (typeof result === 'function') {
result = result();
}
return result;
};
}
// Returns a thunk instead of calling itself directly
function _factorial(n, acc = 1) {
if (n <= 1) return acc;
return () => _factorial(n - 1, n * acc);
}
const factorial = trampoline(_factorial);
console.log(factorial(100000)); // β
no stack overflow (prints Infinity; use BigInt for exact values)Why it works: each call returns a thunk immediately, so the stack never grows beyond a couple of frames. The repetition happens in the while loop, not on the call stack.
Alternative 2: rewrite it as a loop
The simplest and fastest approach: use a loop. Recursion is a mental model; iteration is how the hardware actually runs.
// Iterative factorial (always stack-safe)
function factorial(n) {
let result = 1;
for (let i = 2; i <= n; i++) {
result *= i;
}
return result;
}
// Recursive tree traversal (overflows on very deep trees)
function sumTree(node) {
if (!node) return 0;
return node.value + sumTree(node.left) + sumTree(node.right);
}
// Iterative version with 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;
}Alternative 3: generators for lazy sequences
Generators let you produce values lazily. Be careful, though: a generator that recurses with yield* still nests one generator per level, so deep recursion can overflow just the same. For large depths, loop inside the generator:
// Recursive: nests one generator per value, so it overflows on long ranges
function* lazyRange(start, end) {
if (start > end) return;
yield start;
yield* lazyRange(start + 1, end);
}
// Iterative: safe at any length
function* range(start, end) {
for (let i = start; i <= end; i++) {
yield i;
}
}
for (const n of range(1, 1000000)) {
// process n
}Alternative 4: raise the stack size (Node.js only)
As a last resort, you can raise V8's stack size with the --stack-size flag:
node --stack-size=65536 app.jsβ οΈ Warning: this is a band-aid, not a fix. It delays the stack overflow without removing it, and setting it beyond what the operating system gives the thread can crash Node.js outright instead of throwing a RangeError. Use trampolining or a loop for production code.
Which approach to use
| Approach | Best for | Readability | Performance | Stack-safe? |
|---|---|---|---|---|
| Trampolining | Keeping a recursive style | β Good | β Good | β Yes |
| Rewrite as a loop | Maximum performance | β οΈ Sometimes verbose | π Best | β Yes |
| Generators | Lazy sequences | β Good | β Good | β οΈ Only when they loop |
| Raise the stack size | Quick prototyping | β No code change | β Good | β No |
| Rely on proper tail calls | Code that only runs on Safari or Bun | β Natural | β Good | β οΈ JavaScriptCore only |
Key takeaways
π§ Don't assume your JS engine optimizes tail calls. Verify, or use a pattern that's safe in every runtime.
Proper tail calls are in the ES2015 spec, but only JavaScriptCore implements them: Safari, every iOS browser, and Bun
V8 (Chrome, Edge, Node.js, Deno) and SpiderMonkey (Firefox) rejected them over debugging and complexity concerns
Even in JavaScriptCore, they only apply in strict mode
Tail-recursive code in Node.js still overflows at roughly 10,000 to 15,000 frames
Trampolining is the most idiomatic functional-style fix: safe and readable
A plain loop is the fastest fix for production code
Generators work well for lazy sequences, as long as they loop instead of recursing
Frequently asked questions
Why does MDN say proper tail calls are "Safari only"?
Because JavaScriptCore, Safari's engine, is the only major engine that shipped proper tail calls as specified in ES2015 and kept them. Chrome's V8 implemented them behind a flag and removed them in 2017, and Firefox never shipped them. Bun isn't a browser, so it doesn't appear in MDN's browser table, but it runs on JavaScriptCore and supports them too.
Does Bun support tail call optimization?
Yes. Bun runs on JavaScriptCore, so proper tail calls work in strict-mode code, which includes every ES module. The same tail-recursive function that overflows in Node.js or Deno runs in constant stack space in Bun. Don't rely on it in a library, though: your users may run it on Node.js.
Do proper tail calls work in Chrome on iPhone?
Yes. Browsers on iOS and iPadOS run on WebKit and its JavaScriptCore engine, so Chrome, Firefox and Edge on an iPhone or iPad behave like Safari. The same browsers on desktop and Android use their own engines and don't support proper tail calls.
Why did V8 remove proper tail calls?
V8 implemented them behind a flag in 2016 and removed them in 2017. The main objection was that eliminated frames disappear from stack traces and DevTools, so developers can't tell how a crash was reached. An alternative, Syntactic Tail Calls (STC), proposed an explicit opt-in syntax (return continue f()) so developers would choose when to lose frames, but TC39 never adopted it.
Is there a way to use tail call optimization in JavaScript today?
Yes, with limits:
JavaScriptCore runtimes: in Safari or Bun, strict-mode tail calls are optimized natively.
Trampolining: return a thunk instead of recursing, and let a loop run the chain. No stack growth, works in every engine.
A Babel plugin: babel-plugin-tailcall-optimization rewrites tail-recursive functions as loops at build time. It works everywhere, but only handles a function calling itself.
Compact trampoline:
function trampoline(fn) {
return (...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);
});
factorial(10000); // works without a stack overflow



