Community Article

Pure vs Tail Recursion and Why JavaScript Doesn't Care

What pure recursion vs tail recursion actually means, why tail-call optimisation matters, why mainstream JS engines refuse to ship it, and the iterative rewrite I default to when stack depth scales with input.

Pure vs Tail Recursion and Why JavaScript Doesn't Care

What pure recursion vs tail recursion actually means, why tail-call optimisation matters, why mainstream JS engines refuse to ship it, and the iterative rewrite I default to when stack depth scales with input.

recursion
call-stack
stack-vs-heap
interview-prep
fundamentals
ezb1981

By @ezb1981

May 9, 2026

·

Updated May 20, 2026

335 views

5

4.6 (10)

A junior engineer on my team submitted a PR with a beautifully written recursive function that summed a deeply nested config tree. Tests passed locally on small fixtures. CI passed because the integration test data was small. Then the same function ran on a customer's production config in QA, hit Node's recursion limit, and crashed. The fix was a four-line rewrite to an iterative loop with an explicit stack. The PR description said "this is tail-recursive so it should be safe for any input". That sentence was wrong, and not because the function was not tail-recursive; it was. The sentence was wrong because JavaScript does not honour tail calls.

This article is about that mismatch. Tail recursion is a real concept with real meaning, but in the languages most of us write (JavaScript, Python, Java, Go), it does not buy you what theory says it should. I want to walk through what tail recursion actually is, what tail-call optimisation does in languages that implement it, why the JS standards body specified it and the engines never shipped it, and what to do instead.

Pure recursion: the function calls itself, then does more work

Pure recursion (sometimes called "non-tail recursion") is the everyday shape: a function calls itself, and then operates on the result before returning.

function sum(n) {
    if (n === 0) return 0;
    return n + sum(n - 1);   // recursive call, then ADD, then return
}

The crucial bit is "then ADD". When sum(5) calls sum(4), the engine cannot discard sum(5)'s stack frame yet, because sum(5) still has work to do once the inner call returns: namely, add 5 to it. So all n frames stay on the stack until the base case fires, and then they unwind in order, each doing its addition. Stack depth is O(n). Stack overflow on large n.

Tail recursion: the recursive call is the entire result

Tail recursion means the recursive call is the very last thing the function does. There is no work after it. The return value of the recursive call is the return value of the current call, unmodified.

function sum(n, acc = 0) {
    if (n === 0) return acc;
    return sum(n - 1, acc + n);   // recursive call IS the return value
}

Notice what changed: the running total is passed forward as a parameter (acc), and the recursive call's return value is returned directly without any further computation. There is no "and then add" step.

Why does that matter? Because in a tail-call position, the calling frame has nothing left to do. The compiler or runtime could, in principle, throw away the calling frame entirely and reuse it for the recursive call. The recursion depth, in terms of actual stack frames in use, would be O(1). The function would be functionally equivalent to a loop.

That transformation is called tail-call optimisation (TCO), or sometimes tail-call elimination (TCE). When a language implements it, tail-recursive functions become safe for arbitrary input depth. When it does not, tail-recursive functions blow the stack the same way pure-recursive ones do.

What TCO actually looks like in compiled output

If you compile the tail-recursive sum in a Scheme or OCaml compiler, the generated assembly is essentially a goto back to the top of the function, with the parameters updated. The stack frame is reused.

In JavaScript V8, the same source code generates a stack frame per call. There is no goto, no frame reuse. The "tail-recursive" function is, at the runtime level, indistinguishable from the pure-recursive one. The only saving grace is the constant factor: tail-recursive code spends less time per frame because there is no post-call work. The function still runs out of stack at exactly the same depth it would have without the rewrite.

Try this in Node.js:

function sum(n, acc = 0) {
    if (n === 0) return acc;
    return sum(n - 1, acc + n);
}
sum(100000);   // Maximum call stack size exceeded

It crashes. The stack limit in V8 is roughly 10,000-15,000 frames depending on frame size, and tail recursion does not move that limit.

Why JavaScript does not have TCO, even though the spec says it should

This is the part that surprises people. ES2015 (the 2015 JavaScript standard) explicitly mandated proper tail calls (PTC). The spec said: tail calls in strict mode must reuse the stack frame. Engines were supposed to implement it.

Then they did not. Safari (JavaScriptCore) shipped TCO and is the only major engine that has it. V8 (Chrome, Node, Edge) refused, and Firefox (SpiderMonkey) did not implement it either. The reasons given were a mix of technical and practical:

The technical objection was that TCO breaks stack traces. If frames are recycled, the debugger cannot show you the chain of calls that led to a crash. Engineers debugging a production stack trace would see only the most recent frame, with everyone above it gone. That is a real cost in a language whose dominant debugging tool is console.log and whose dominant production debugging tool is "look at the stack".

The practical objection was that TCO is a "trap door" feature: if it is not in every engine, JS authors cannot rely on it, so writing tail-recursive code is no safer than writing pure-recursive code on the runtimes that lack TCO. Without ubiquitous support, the feature might as well not exist for production code. And without production code using it, engines have no incentive to ship it.

The result is a dead spec feature. ES2015 says one thing, every engine except JavaScriptCore says another. There has been talk of "syntactic tail calls" (a return continue someFunc(...) syntax that explicitly opts in) for the last seven years; nothing has shipped.

Python, Java, and Go also refuse, and the reasons echo

Python is even more explicit. Guido van Rossum wrote a blog post in 2009 titled "Tail Recursion Elimination" arguing against it on principle: he believes Python's flat call-stack model and clear stack traces are more valuable than the ability to write tail-recursive code, and recursion is rarely the right tool in Python anyway. The CPython interpreter has no TCO and never will under that philosophy.

Java does not have TCO at the language level either. The JVM spec leaves it open, but neither HotSpot nor most other JVMs eliminate tail calls. Some compiled languages on the JVM (Scala) implement local tail-call optimisation when the recursive call is to the same method; cross-method tail calls still allocate frames.

Go specifically rejected TCO; the Go team felt explicit loops were cleaner and stack traces were too important.

The pattern is clear: languages designed for industry use overwhelmingly choose stack-trace clarity over TCO. Languages designed for functional programming (Scheme, Standard ML, OCaml, Haskell, Erlang) overwhelmingly choose TCO and accept the debugging cost. JS, Python, Java, and Go all sit in the first camp.

What to do instead: the iterative rewrite

If your language does not have TCO, and the recursion depth scales with input, you cannot rely on the recursion. The fix is the same in every language: convert to an iterative loop with an explicit accumulator, or with an explicit stack if the recursion is non-linear.

For linear recursion (one recursive call per invocation), the loop is trivial.

function sum(n) {
    let acc = 0;
    for (let i = n; i > 0; i--) {
        acc += i;
    }
    return acc;
}

For tree recursion (multiple recursive calls per invocation, like a binary tree traversal), use an explicit stack.

function sumTree(root) {
    if (!root) return 0;
    let total = 0;
    const stack = [root];
    while (stack.length) {
        const node = stack.pop();
        total += node.value;
        if (node.left) stack.push(node.left);
        if (node.right) stack.push(node.right);
    }
    return total;
}

The stack-as-array version uses heap memory instead of stack memory. Heap memory is much more abundant; in Node.js you can typically allocate a stack-array of millions of entries before hitting GC trouble, while you cannot fit a thousand recursive frames on the call stack. The conversion is mechanical and the resulting code reads almost the same.

Tail-call optimisation is a language feature, not a coding style

The practical takeaway from all this is simple: tail recursion is a structural property of code, but tail-call optimisation is a runtime feature. Writing your function in a tail-recursive style does not buy you anything if the runtime does not eliminate the call. In Scheme, OCaml, Haskell, and Erlang, the style and the optimisation work together; tail-recursive functions are genuinely as cheap as loops. In JavaScript, Python, Java, and Go, the style is purely cosmetic; the runtime treats it the same as pure recursion and the stack overflows at the same depth. So when I review JS or Python code that recurses on a structure whose depth scales with input, I do not accept "this is tail-recursive" as a defence. I ask whether the recursion depth is bounded by something small (a balanced tree of height log n is fine; a left-leaning linked list of depth n is not), and if it is not, I ask for the iterative rewrite. The four-line conversion is cheaper than the late-night incident, and it always will be until V8 changes its mind.

Back to Articles