Tail calls
Learn what tail calls are, how ES2015 proper tail calls work, why most engines skip them, and how trampolines avoid stack overflow.
- 01Identify real tail positionsTell why
return f(x)is a tail call, whyreturn 1 + f(x)is not, and why the right side of||,&&, and??can be tail position. - 02Explain ES2015 PTCDescribe strict-mode proper tail calls as a semantic stack guarantee, then name the engine-support and debugging trade-offs.
- 03Choose practical workaroundsUse accumulators, loops, explicit stacks, or trampolines when production JavaScript must handle deep recursion today.
A tail call is the last thing a function does
You already know from Stack overflow that ordinary recursion can run out of call stack. This lesson asks a more precise question: when a function calls another function, can the caller leave before the callee starts?
A tail call is a function call whose result is returned directly by the current function. If the caller has no more JavaScript work to do after the callee returns, the call is in tail position.
That definition is about the source code. A proper tail call, or PTC, is the stronger engine behavior: the engine does not keep adding caller frames for qualifying tail calls. ES2015 specifies that behavior for strict-mode tail calls, but only Safari's JavaScriptCore ships it in practice today.
Write one task, cross it out, then write the next task. The list stays short because the old task is finished before the next one begins. A tail call has that same handoff shape.
- In real life: Cross out the current task before writing the next
- In JavaScript:
return nextStep(task)is a tail call - In real life: Keep the task because work remains after the next one
- In JavaScript:
return add(nextStep(task))is not tail position - In real life: A clean handoff leaves no old task to keep
- In JavaScript: The caller frame can be discarded or reused
- In real life: Keeping each task makes the list grow
- In JavaScript: Most engines keep frames for tail recursion
Where the analogy stops: A paper list does not run code. JavaScript engines must preserve language behavior and useful debugging information.
We will keep the scope narrow. Advanced recursion covers recursive problem solving. The neighbouring Stack overflow lesson covers limits and explicit-stack iteration. The stack-frames lesson covers frame contents. Here we focus on tail positions, ES2015 PTC, and portable workarounds.
What counts as tail position?
SOURCE RULESStart with tiny code. Line 9 is a tail call because direct returns exactly what finish returns. Line 13 is not a tail call because addAfter must still concatenate "value ". Line 17 looks surprising: if the right side of || runs, that call's result becomes the whole returned value, so the spec treats it as tail position.
"use strict"; function finish(label) { console.log("finish " + label); return label;} function direct() { return finish("direct");} function addAfter() { return "value " + finish("add");} function rightSideTail(ready) { return ready || finish("right");} console.log(direct());console.log(addAfter());console.log(rightSideTail(false));The output is ordinary JavaScript: finish direct, then direct, then finish add, then value add, then finish right, then right. Runtime output does not tell you whether an engine reused a frame; it only proves the calls ran in that order.
| Code shape | Classification | Reason |
|---|---|---|
return f(x); | Tail position | The caller immediately returns the callee's result. |
return 1 + f(x); | Not tail position | The caller still has to add 1 after f returns. |
return f(x) || fallback; | Not tail position | The call is the left operand; the caller may still need to test it and choose fallback. |
return ready || f(x); | Tail position when the right side runs | The spec's tail-position rule recurses into the right operand of ||. |
return ok ? f(x) : g(x); | Tail position for both calls | Each branch result is returned directly. |
const value = f(x); return value; | Not tail position | The call happens before the return statement, so the frame cannot be discarded for that call. |
The ECMAScript spec's static semantics are precise. It checks strict mode first, then asks whether the specific call appears in a return expression's tail position. For short-circuit expressions, it recurses into the right operand of ||, &&, and ??, not the left operand.
return finish(order);return 1 + finish(order);return cached || loadFresh();return loadFresh() || cached;return ok ? sendA() : sendB();return continue sendA();
Sort each source shape. Focus on whether the caller still has work after the call.
Proper tail calls in ES2015
SPEC VS ENGINEES2015 added a semantic requirement called proper tail calls. In plain words: for a strict-mode call in tail position, the engine must release or reuse the current execution context before invoking the target. The spec names this operation PrepareForTailCall.
Two details matter. First, the spec only defines tail-position calls for strict-mode code. That avoids old caller-chain features from sloppy mode. Second, PTC changes stack growth, not the final value. A PTC engine and a non-PTC engine should both compute the same sum; they differ in whether deep tail recursion keeps piling frames.
"use strict"; function countDown(n) { if (n === 0) return "done"; return countDown(n - 1);} function sumTo(n, acc = 0) { if (n === 0) return acc; return sumTo(n - 1, acc + n);} function trampoline(thunk) { let current = thunk; while (typeof current === "function") current = current(); return current;} function countDownThunk(n) { if (n === 0) return "done"; return () => countDownThunk(n - 1);} function sumThunk(n, acc = 0) { if (n === 0) return acc; return () => sumThunk(n - 1, acc + n);} console.log("node", process.version);console.log("v8", process.versions.v8);try { console.log("plain-countdown", countDown(100000));} catch (error) { console.log("plain-countdown", error.name);}try { console.log("tail-sum", sumTo(100000));} catch (error) { console.log("tail-sum", error.name);}console.log("trampoline-countdown", trampoline(() => countDownThunk(100000)));console.log("trampoline-sum", trampoline(() => sumThunk(100000)));node v22.x
v8 12.4.x
plain-countdown RangeError
tail-sum RangeError
trampoline-countdown done
trampoline-sum 5000050000The lesson test runs that program in a child Node process. It accepts any Node v22.x patch and V8 12.4.x build, then proves the strict tail-recursive countdown and accumulator throw RangeError while the trampoline versions finish.
That is why you should not write production JavaScript that depends on PTC unless your supported engine list is explicit and tested. If your code must handle arbitrary depth, use a portable pattern.
Why most engines do not ship proper tail calls
SUPPORTWebKit's JavaScriptCore ships proper tail calls and documents the memory benefit: tail-deleted frames do not remain on the stack. It also documents the debugging problem and its ShadowChicken machinery, which reconstructs useful call history while the debugger is active.
V8 implemented and staged ES2015 proper tail calls, then moved away from shipping them. The V8 team wrote that elided frames make debugging and error.stack less useful, and that always-on shadow stacks are too expensive. Mozilla and Microsoft committee members co-championed an explicit syntactic opt-in instead.
| Engine family | PTC status | Practical note |
|---|---|---|
| JavaScriptCore / Safari | Ships ES2015 proper tail calls | WebKit documents PTC and uses debugging machinery called ShadowChicken to reconstruct useful stacks while debugging. |
| V8 / Chrome / Node | Does not ship PTC today | V8 implemented and staged ES2015 PTC, then favored explicit syntax because elided frames hurt debugging and always-on shadow stacks cost performance. |
| SpiderMonkey / Firefox | Does not ship PTC today | Mozilla co-championed syntactic tail calls as an explicit opt-in instead of silent frame elision for existing code. |
| Hermes, QuickJS, most embedded engines | Do not rely on PTC | Treat PTC as unavailable unless the engine's own documentation says otherwise. |
If a tail call deletes the caller frame, a stack trace can skip the function that handed off the work. That is great for memory, but confusing for a developer reading production telemetry. The next lesson, Stack traces & the stack trace API, builds directly on that trade-off.
A teaching model: reuse the frame or keep piling them up
STEP THROUGHThe accumulator version of sumTail puts the pending addition into the argument before the recursive call. Line 5 returns the recursive call directly, so a PTC engine can hand off and reuse the current frame. V8 in Node does not, so the ordinary stack still grows.
Step through a tail-recursive accumulator. The side-by-side frame counters are a teaching model; they do not inspect your browser's engine.
script
"use strict"; function sumTail(n, acc = 0) { if (n === 0) return acc; return sumTail(n - 1, acc + n);} This is a teaching model, not a browser inspector. The code really computes 10; the side-by-side counters label what a proper-tail-call engine may do compared with what V8 does today.
Trampolines: return the next step instead of calling it
WORKAROUNDA thunk is a zero-argument function that delays work. A trampoline is a loop that keeps calling thunks until it gets a plain value. Instead of growing the JavaScript call stack, every recursive step returns to the same loop.
In a relay, each runner hands over the baton and leaves the track. The next runner starts with the baton, so earlier runners do not stay in a growing line. A trampoline returns each next step to one loop before that loop runs it.
- In real life: A runner hands over the baton
- In JavaScript: A recursive function returns a thunk
- In real life: One runner moves at a time
- In JavaScript: The trampoline loop calls one thunk
- In real life: The first runner leaves the track
- In JavaScript: Each step returns to the loop with a flat stack
- In real life: The last runner crosses the finish line
- In JavaScript: A non-function value stops the trampoline
Where the analogy stops: Runners move at the same time in a real race. A JavaScript trampoline runs one returned function at a time and adds allocation overhead.
Watch a trampoline turn recursive intentions into loop iterations. The code really computes the sum; the recording simply exposes each step.
script
function trampoline(thunk) { let current = thunk; let hops = 0; while (typeof current === "function") { current = current(); hops += 1; } return { value: current, hops };} function sumThunk(n, acc = 0) { if (n === 0) return acc; return () => sumThunk(n - 1, acc + n);} console.log(result.value);console.log(result.hops);The code prints 10 and then 5. The extra hop is the initial thunk passed to trampoline. For n = 100000, the same shape stays stack-safe in Node where plain tail recursion throws.
Try plain recursion and a trampoline in your browser
YOUR RUNTIMENow run both versions where you are reading. The plain version is strict and tail-recursive. The trampoline version uses thunks. The output is labelled your browser because Safari can differ from Chromium and Firefox.
"use strict"; function countDown(n) { if (n === 0) return "done"; return countDown(n - 1);} function trampoline(thunk) { let current = thunk; while (typeof current === "function") current = current(); return current;} function countDownThunk(n) { if (n === 0) return "done"; return () => countDownThunk(n - 1);}no result yetPress Run comparison to measure this browser, then Reset to return here.
Press Run comparison to try the plain recursive call and trampoline in this browser. No engine-dependent result is computed during page render.
Use small numbers first, then raise the input. If the plain call throws RangeError, the trampoline should still return done. If the plain call returns too, either the input is shallow enough or your engine implements PTC for that case.
Syntactic tail calls: the explicit opt-in that did not land
PROPOSALThe syntactic tail calls proposal tried to make the handoff explicit. The commonly shown spelling was return continue f(x). That would let developers opt into missing caller frames and let engines reject code that looked like a tail call but could not be guaranteed.
function factorial(n, acc = 1) { if (n <= 1) return acc; return continue factorial(n - 1, acc * n);}TC39's inactive-proposals list includes “Updates to Tail Calls to include an explicit syntactic opt-in” and links to the tc39/proposal-ptc-syntax repository. Node 22 parses return continue as a syntax error.
It is still useful vocabulary. When someone says “syntactic tail calls,” they mean an explicit source-level marker, not the silent ES2015 PTC rule.
What should working developers do?
PRACTICALMost application code should stay readable. If a tree is shallow and trusted, ordinary recursion is fine. If the depth is user-controlled, generated, or unbounded, do not depend on PTC. Choose a loop, an explicit stack, or a trampoline.
function visitMenu(node, path = []) { const nextPath = path.concat(node.label); if (node.children.length === 0) return [nextPath.join(" > ")]; return node.children.flatMap((child) => visitMenu(child, nextPath));} const menu = { label: "Home", children: [ { label: "Courses", children: [{ label: "JavaScript", children: [] }] }, { label: "Pricing", children: [] }, ],}; console.log(visitMenu(menu).join(" | "));Line 4 recurses inside flatMap. That is clear for small menus, but it is not a portable stack-safety strategy for a huge user-generated tree. For deep data, rewrite the traversal with your own stack of nodes, or use a trampoline if keeping a recursive shape is valuable.
| Approach | How it avoids stack growth | When to use it |
|---|---|---|
| Proper tail calls | Engine reuses or discards the caller frame for a strict tail-position call. | Great when available, but not portable across mainstream engines. |
| Loop or explicit stack | Rewrite the recursion as while, for, or your own array of work. | Most predictable production choice for deep traversals. |
| Trampoline | Return thunks and let one loop call them until a value appears. | Portable and stack-safe, but allocates functions and is less direct to read. |
| Syntactic tail calls | A proposed return continue f(x) opt-in. | Inactive proposal; useful vocabulary, not current JavaScript syntax. |
- Measure first when performance is the reason for changing code.
- Choose loops or explicit stacks for untrusted depth.
- Use trampolines when the recursive shape is important and allocation overhead is acceptable.
- Do not rely on PTC unless your supported engine matrix proves it.
Common misconceptions
“Tail recursion is always stack safe.”
Not in V8, Node, Chromium, or Firefox today. Tail recursion is a source-code shape. Stack safety needs engine PTC or a workaround.
“Only `return f()` can be tail position.”
Parentheses, conditional branches, comma expressions' final expression, and the right side of short-circuit operators can also contain tail-position calls. The exact spec rule matters.
“PTC is just an optimization engines may skip.”
In the spec, proper tail calls are a semantic stack guarantee for qualifying strict-mode calls. Engine support is the practical catch.
“A trampoline is free.”
It is portable, but it allocates thunks and changes readability. Use it when the stack-safety benefit is worth the shape.
| Idea | What it means | What it does not mean |
|---|---|---|
| Tail call | A source-code position: the call's result is returned directly. | It does not promise stack safety by itself. |
| Proper tail call | An implementation guarantee that a qualifying tail call does not grow the stack unboundedly. | ES2015 specifies it for strict-mode tail positions, but most engines do not ship it. |
| Tail recursion | A recursive call that is also a tail call. | It is still stack-recursive in V8, Node, and most browsers today. |
| Trampoline | A library pattern that moves recursion into a loop of thunks. | It changes your code shape; it is not the engine doing PTC. |
Practice exercises
Read the code and type the two console lines in order.
"use strict";
function finish(label) {
console.log("finish " + label);
return label;
}
function notTail() {
return "value " + finish("add");
}
console.log(notTail());finish logs first and returns add. Then notTail creates value add, and the outer console.log prints it.
In return cached || finish(), is finish() in tail position?
Yes. In return cached || finish(), the right operand call is in tail position when it runs. The left operand of || would not be.
What error name does the strict tail-recursive countdown hit in Node 22?
Node 22's V8 throws RangeError for the strict tail-recursive countdown at depth 100000 in this lesson's proof.
How many loop hops does the trampoline report for sumThunk(4)?
function trampoline(thunk) {
let current = thunk;
let hops = 0;
while (typeof current === "function") {
current = current();
hops += 1;
}
return { value: current, hops };
}
function sumThunk(n, acc = 0) {
if (n === 0) return acc;
return () => sumThunk(n - 1, acc + n);
}
console.log(trampoline(() => sumThunk(4)).hops);The answer is 5: one hop starts sumThunk(4), then the thunks for 3, 2, 1, and the final 0 complete the loop.
What is the current TC39 status of the return continue proposal?
function factorial(n, acc = 1) {
if (n <= 1) return acc;
return continue factorial(n - 1, acc * n);
}The bug is assuming return continue is current JavaScript. It belongs to the inactive syntactic-tail-calls proposal, so production code must use another pattern.
Name one portable workaround you could use for a deeply nested menu, comment tree, or file browser.
function collectLabels(root) {
const stack = [{ node: root, path: [] }];
const labels = [];
while (stack.length > 0) {
const { node, path } = stack.pop();
const nextPath = path.concat(node.label);
if (node.children.length === 0) labels.push(nextPath.join(" > "));
for (const child of node.children) stack.push({ node: child, path: nextPath });
}
return labels;
}For a CMS menu, comment tree, or file browser with unknown depth, an explicit stack keeps the work portable and avoids depending on PTC.
Check your understanding
QUIZUse the source-position rules first, then the engine-support facts.
Question 1 of 7What is a tail call?
Choose an answer to see the explanation.
Question 2 of 7What does this snippet print?
Read the code, then predict"use strict"; function finish(value) { console.log("finish " + value); return value; } function demo() { return "value " + finish("add"); } console.log(demo());Choose an answer to see the explanation.
Question 3 of 7In ES2015's proper-tail-call rules, which mode is required before a call can be considered tail position?
Choose an answer to see the explanation.
Question 4 of 7Which call is in tail position according to the spec's short-circuit expression rules?
Choose an answer to see the explanation.
Question 5 of 7What does Node 22's V8 do with a strict tail-recursive
countDown(100000)in this lesson's proof?Choose an answer to see the explanation.
Question 6 of 7What does the trampoline example print?
Read the code, then predictfunction trampoline(thunk) { let current = thunk; let hops = 0; while (typeof current === "function") { current = current(); hops += 1; } return { value: current, hops }; } function countDownThunk(n) { if (n === 0) return "done"; return () => countDownThunk(n - 1); } console.log(trampoline(() => countDownThunk(3)).value);Choose an answer to see the explanation.
Question 7 of 7What is
return continue f(x)today?Choose an answer to see the explanation.
Key takeaways
- A tail call returns another call's result directly; work after the call makes it non-tail.
- ES2015 proper tail calls are specified for strict-mode tail positions, but most engines do not ship them.
- Safari's JavaScriptCore is the notable shipping browser implementation; V8/Node and SpiderMonkey should be treated as non-PTC.
- Trampolines, loops, and explicit stacks are the portable answers for deep recursion today.
One-line summary: tail position is a source-code shape; stack safety comes from engine PTC or from a workaround you write.
Next, Stack traces & the stack trace API shows what those frames look like when JavaScript reports errors.