Tail calls
Learn what tail calls are, how ES2015 proper tail calls work, why most engines skip them, and how trampolines avoid stack overflow.
- 01Spot tail callsSay why
return f(x)is a tail call, whyreturn 1 + f(x)is not, and why a call on the right side of||,&&, or??can be one. - 02Explain proper tail callsExplain the ES2015 rule: in strict mode, the engine drops or reuses a frame for a tail call. Then say why most engines don't do it, and how that affects debugging.
- 03Choose a safe workaroundUse an accumulator, a loop, your own stack, or a trampoline when your code must handle very deep recursion today.
A tail call is the last thing a function does
In the Stack overflow lesson, you saw that recursion can run out of room. (Recursion means a function that calls itself.) Every call that has not finished yet takes up space on the call stack. The space for one call is called a frame. A frame holds the call's parameters and the place to go back to when the call ends.
This lesson asks one question. When a function calls another function as its very last step, does the first function still need its frame?
A tail call is a call whose result the function returns straight away, with no more work after it. For example, return finish(). We say the call is in tail position. If the function still has work to do after the call, such as adding to the result, the call is not in tail position.
An engine is the part of a browser or Node that runs your JavaScript. Some engines can make use of tail calls. A proper tail call, or PTC, means the engine drops or reuses the calling function's frame before the tail call starts. Then the stack does not grow.
ES2015, the 2015 edition of JavaScript, asks every engine to do this in strict mode. (Strict mode is the stricter set of rules you turn on by writing "use strict" at the top of a file or function.) In practice, only Safari's engine, JavaScriptCore, does it today.
You write one task on a list. You finish it and cross it out. Then you write the next task. The list stays short, because each task is done before the next one starts. A tail call hands off work in the same way.
- In real life: Cross out the task, then write the next one
- In JavaScript:
return nextStep(task)is a tail call - In real life: Keep the task, because work is left after the next one
- In JavaScript:
return add(nextStep(task))is not a tail call - In real life: A crossed-out task needs no space on the list
- In JavaScript: The engine can drop or reuse the calling function's frame
- In real life: Keeping every task makes the list long
- In JavaScript: Most engines keep a frame for every tail call
This lesson stays on tail calls. Other lessons cover the topics next to it:
- Advanced recursion shows how to solve problems with recursion.
- The Stack overflow lesson covers stack limits and how to swap recursion for a loop.
- The Stack frames lesson covers what is inside a frame.
Here you will learn three things: which calls are in tail position, what proper tail calls are, and what to use instead today.
Which calls are in tail position?
THE RULESStart with a small program. It has three functions, and each one calls finish(). finish() prints a line and returns the label it was given.
"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));- Line 9 is a tail call.
direct()returns exactly whatfinish()returns. - Line 13 is not a tail call. After
finish()returns,addAfter()still has to put"value "in front of the result. - Line 17 may surprise you.
||means "or". Ifreadyis false, JavaScript runs the right side and returns its result. That result is the whole return value, so the call on the right is in tail position.
direct() is done once it calls finish(). addAfter() must wait, because it still puts "value " in front of the answer.The program prints six lines, in this order: finish direct, direct, finish add, value add, finish right, right. The output can't tell you whether the engine reused a frame. It only shows the order the calls ran in.
Each row of the next table is one way to write a return. Read across: the code, whether the call is in tail position, and why.
| When you write | Is the call in tail position? | Why |
|---|---|---|
return f(x); | Yes. | The function returns the result of f straight away. |
return 1 + f(x); | No. | The function still has to add 1 after f returns. |
return f(x) || fallback; | No. | The call is on the left. The function must still check its result, and may return fallback instead. |
return ready || f(x); | Yes, when the right side runs. | The result of f is then the whole return value. The spec looks at the right side of ||. |
return ok ? f(x) : g(x); | Yes, for both calls. | Each branch's result is returned straight away. |
const value = f(x); return value; | No. | The call happens before the return line, so the function is not done when it calls f. |
The JavaScript specification (the spec) is the official rulebook for the language. Its tail-call rule has two checks:
- The code must be in strict mode.
- The call must be the last thing in a
return.
|| ("or"), && ("and"), and ?? ("use the right side if the left is null or undefined") may skip their right side. For these three, the spec looks only at the right side. A call on the left is never in tail position, because JavaScript must still check its result.
return finish(order);return 1 + finish(order);return cached || loadFresh();return loadFresh() || cached;return ok ? sendA() : sendB();return continue sendA();
Sort each line of code. Ask: does the function still have work to do after the call?
Proper tail calls in ES2015
RULE VS REALITYES2015 added a rule called proper tail calls. It says: for a tail call in strict-mode code, the engine must drop or reuse the current function's frame before it starts the next call. In the spec, the step that does this is called PrepareForTailCall.
Two details matter:
- The rule only applies in strict mode. Older, non-strict code has features that look back at the calling function, and those need its frame to stay.
- The rule changes how much stack space a program uses. It does not change the answer. An engine with PTC and an engine without it compute the same sum. The only difference is whether deep tail recursion keeps adding frames.
The next program runs only in Node. V8 is the engine inside Chrome and Node. The program calls two strict tail-recursive functions 100000 levels deep: countDown on line 5 and sumTo on line 10. Lines 41 and 42 do the same work with a trampoline, which you will meet later in this lesson. A RangeError here means the stack ran out of room.
"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's test runs this program in a separate Node process. It accepts any Node v22.x and any V8 12.4.x. It checks that the strict countdown and sum both throw RangeError, and that both trampoline versions finish.
So don't write JavaScript that needs PTC to work, unless you know exactly which engines will run it and you have tested them. If your code must handle any depth, use one of the safe patterns later in this lesson.
Why most engines don't do proper tail calls
SUPPORTWebKit is the project behind Safari. Its engine, JavaScriptCore, does proper tail calls. WebKit says the gain is memory: frames that a tail call removes don't stay on the stack.
WebKit also names the cost: those frames are missing when you debug. To help, it has a tool called ShadowChicken. While the debugger is open, ShadowChicken keeps a separate record of recent calls, so it can show the missing frames.
The V8 team built proper tail calls and tested them. Then they decided not to turn them on. They gave two reasons:
- Missing frames make debugging and
error.stackless useful. (error.stackis the text that lists the calls that led to an error.) - Keeping a separate record of calls all the time, just in case, costs too much speed and memory.
People from Mozilla (who make Firefox) and Microsoft on the JavaScript standards committee agreed. They suggested a different idea: a special word you write in the code to ask for a tail call. You will see it later in this lesson.
Each row of the next table is one engine family. Read across: the engine, whether it does proper tail calls, and what that means for you.
| Engine | Does it do proper tail calls? | What to know |
|---|---|---|
| JavaScriptCore (Safari) | Yes. | WebKit documents proper tail calls. While you debug, a tool called ShadowChicken keeps a record of calls, so it can show the missing frames. |
| V8 (Chrome, Node) | No, not today. | The V8 team built proper tail calls and then turned them off. Missing frames made debugging harder, and keeping a record of calls all the time cost too much speed. |
| SpiderMonkey (Firefox) | No, not today. | Mozilla preferred a mark you write in the code to ask for a tail call, so old code would not lose frames without warning. |
| Hermes, QuickJS, and most engines built into other apps | Don't count on it. | Assume they don't, unless the engine's own documentation says they do. |
A stack trace is the list of calls that led to an error. When a tail call drops the calling function's frame, the stack trace can skip the function that handed off the work. That saves memory. But it can confuse a developer who reads error reports from users. The next lesson, Stack traces & the stack trace API, builds on this.
A model: reuse one frame, or keep adding frames
STEP THROUGHHere is sumTail. It adds the numbers from n down to 1. It uses an accumulator: an extra parameter, acc, that carries the running total from call to call.
On line 5, the addition acc + n happens first, inside the arguments. Then line 5 returns the call directly, with nothing left to add. So it is a tail call. An engine with PTC can reuse the current frame for the next call. V8 in Node does not, so its stack grows by one frame for each call.
n and acc.Step through sumTail(4). The two frame counters are a model to help you learn. They don't look inside 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 model to help you learn. It does not look inside your browser. The code really computes 10. The two frame counters show what an engine with PTC may do, next to what V8 does today.
Trampolines: return the next step instead of calling it
WORKAROUNDYou can avoid deep stacks today, in every engine, with two small ideas:
- A thunk is a function with no parameters that holds work for later, such as
() => sumThunk(3, 4). Nothing runs until something calls it. - A trampoline is a loop that keeps calling thunks. It stops when it gets back something that is not a function. That value is the answer.
The recursive function doesn't call itself. Instead, it returns a thunk for the next step. Then the loop calls that thunk. Each step ends and goes back to the loop, so the call stack never grows.
In a relay race, each runner hands the baton to the next runner and leaves the track. Runners who are done don't stay on the track. A trampoline works the same way: each step hands back the next step, then ends.
- In real life: A runner hands over the baton
- In JavaScript: The function returns a thunk
- In real life: Only one runner runs at a time
- In JavaScript: The trampoline loop calls one thunk at a time
- In real life: The runner who is done leaves the track
- In JavaScript: Each step ends and goes back to the loop
- In real life: The last runner crosses the finish line
- In JavaScript: A value that is not a function stops the loop
10, which is not a function, the loop stops.Watch a trampoline do recursive work with a loop. The code really computes the sum. The recording shows 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 5 is the number of hops, meaning turns of the loop. There are five hops, not four, because the first thunk, () => sumThunk(4), counts too. With n = 100000, the same code still works in Node, where plain tail recursion throws.
Try plain recursion and a trampoline in your browser
YOUR RUNTIMENow run both versions in the browser you are using. The plain version is strict and tail-recursive. The trampoline version uses thunks. The result is labelled your browser, because Safari can give a different result from Chrome 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 resultPress Run comparison to try this browser. Press Reset to come back here.
Press Run comparison to run both versions in this browser. Nothing runs until you press it.
Start with small numbers, then try bigger ones. If the plain version throws RangeError, the trampoline should still return done. If the plain version also returns done, there are two possible reasons:
- The number is small enough to fit on the stack.
- Your browser does proper tail calls for this code, as Safari does.
Syntactic tail calls: a proposal that did not make it
PROPOSALA proposal is a suggested new feature for JavaScript. TC39, the committee that decides on JavaScript features, reviews each one. The syntactic tail calls proposal wanted you to mark a tail call in your code. The usual spelling was return continue f(x).
The mark would do two jobs:
- It would say: "I am fine with this function's frame being dropped."
- The engine could stop with an error if the marked call was not really a tail call.
function factorial(n, acc = 1) { if (n <= 1) return acc; return continue factorial(n - 1, acc * n);}TC39 keeps a list of inactive proposals, meaning work on them has stopped. This one is on it, as "Updates to Tail Calls to include an explicit syntactic opt-in". The list links to the tc39/proposal-ptc-syntax repository. Node 22 treats return continue as a syntax error.
The name is still worth knowing. When people say "syntactic tail calls", they mean a mark you write in the code. That is different from the ES2015 rule, which applies to tail calls with no mark at all.
What should you do in real code?
PRACTICALMost code should stay easy to read. Choose by how deep your data can get:
- If the data is shallow and you trust it, plain recursion is fine.
- If users or other programs decide the depth, or there is no limit, don't depend on PTC. Use a loop, your own 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 calls visitMenu again for each child, inside flatMap. (flatMap runs a function on each item and joins the arrays it gets back into one array.) This is clear for small menus. But it is not a safe plan for a huge menu that users built.
For deep data, use your own stack: an array of nodes still to visit, handled by a while loop. Or use a trampoline, if keeping the recursive shape matters to you.
Each row of the next table is one way to handle deep work. Read across: the approach, how it keeps the stack small, and when to use it.
| Approach | How it keeps the stack small | When to use it |
|---|---|---|
| Proper tail calls | The engine drops or reuses the function's frame for a strict-mode tail call. | Great when you have it, but most engines don't. |
| A loop, or your own stack | Rewrite the recursion with while or for, and keep the work still to do in your own array. | The most predictable choice for very deep data in real code. |
| Trampoline | Return thunks, and let one loop call them until a value that is not a function comes back. | Works in every engine, but creates a function per step and is harder to read. |
| Syntactic tail calls | A proposed mark, return continue f(x), to ask for a tail call. | An inactive proposal. Worth knowing the name, but it is not JavaScript today. |
- If speed is your reason for changing code, measure first.
- If you don't trust the depth, use a loop or your own stack.
- Use a trampoline when the recursive shape matters and the extra functions it creates are fine.
- Don't depend on PTC unless you have tested every engine that will run your code.
Common misconceptions
"Tail recursion is always safe from stack overflow."
Not in V8, Node, Chrome, or Firefox today. Tail recursion only describes how the code is written. To be safe from stack overflow, you need an engine with PTC or a workaround.
"Only return f() can be a tail call."
A call can also be in tail position inside brackets, in either branch of ok ? f() : g(), as the last item in a comma list like (log(), f()), or on the right side of ||, &&, and ??. The spec's exact rule decides.
"PTC is just a speed trick that engines may skip."
In the spec, proper tail calls are a promise about stack space for tail calls in strict mode. It is a rule, not an option. The problem is that most engines don't follow it.
"A trampoline costs nothing."
A trampoline works in every engine. But it creates a new function for each step, and the code is harder to read. Use it when safety from stack overflow is worth that cost.
These four names sound alike. Each row says what one name means, and what it does not mean.
| Name | What it means | What it does not mean |
|---|---|---|
| Tail call | A call whose result the function returns straight away. It is about how the code is written. | It does not, on its own, keep you safe from stack overflow. |
| Proper tail call | The engine's promise that a tail call does not add a frame to the stack. | It is not something you can count on. ES2015 asks for it in strict mode, but most engines don't do it. |
| Tail recursion | A function that calls itself as a tail call. | It is not safe from stack overflow in V8, Node, and most browsers today. |
| Trampoline | A pattern you write yourself: a loop that calls thunks one at a time. | It is not the engine doing proper tail calls. It changes how your code looks. |
Practice exercises
Read the code. Type the two printed lines in order.
"use strict";
function finish(label) {
console.log("finish " + label);
return label;
}
function notTail() {
return "value " + finish("add");
}
console.log(notTail());finish prints finish add and returns add. Then notTail builds value add, and the outer console.log prints it.
In return cached || finish(), is finish() in tail position?
Yes. In return cached || finish(), the call on the right is in tail position when it runs. A call on the left of || would not be.
What error name does the strict tail-recursive countdown hit in Node 22?
In this lesson's Node program, Node 22 throws RangeError for the strict tail-recursive countdown 100000 levels deep. Its engine, V8, keeps every frame.
How many hops (turns of the loop) 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. The first hop runs sumThunk(4). Then the thunks for 3, 2, 1, and 0 make four more hops.
What is the TC39 status of the return continue proposal today?
function factorial(n, acc = 1) {
if (n <= 1) return acc;
return continue factorial(n - 1, acc * n);
}The bug is thinking return continue is JavaScript today. It comes from the inactive syntactic tail calls proposal, so real code must use another pattern.
Name one workaround that works in every engine, for a deeply nested menu, comment thread, 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 site menu, a comment thread, or a file browser with unknown depth, your own stack keeps the work safe in every engine. You don't depend on PTC.
Check your understanding
QUIZFirst use the rules for tail position. Then use the facts about which engines do proper tail calls.
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 the ES2015 rules for proper tail calls, which mode must the code be in before a call can count as a tail call?
Choose an answer to see the explanation.
Question 4 of 7Which call is in tail position, under the spec's rules for
||?Choose an answer to see the explanation.
Question 5 of 7In this lesson's Node program, what does Node 22 (with V8) do with the strict tail-recursive
countDown(100000)?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 straight away. If work is left after the call, it is not a tail call.
- ES2015 asks for proper tail calls in strict mode, but most engines don't do them.
- Safari's engine, JavaScriptCore, is the main browser engine that does them. Treat V8 (Chrome and Node) and SpiderMonkey (Firefox) as engines without them.
- Trampolines, loops, and your own stack are the safe answers for deep recursion today.
One-line summary: tail position is about how the code is written; safety from stack overflow comes from an engine with PTC, or from a workaround you write.
Next, Stack traces & the stack trace API shows what those frames look like when JavaScript reports an error.