completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Tail calls

Learn what tail calls are, how ES2015 proper tail calls work, why most engines skip them, and how trampolines avoid stack overflow.

By the end, you can
  • 01
    Spot tail callsSay why return f(x) is a tail call, why return 1 + f(x) is not, and why a call on the right side of ||, &&, or ?? can be one.
  • 02
    Explain 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.
  • 03
    Choose 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?

Definition

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.

Real-life analogyA to-do list that never grows

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 RULES

Start with a small program. It has three functions, and each one calls finish(). finish() prints a line and returns the label it was given.

Which calls are tail calls?Pop out in the code editor (opens in a new tab)JavaScript
"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 what finish() 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". If ready is 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.
Left: direct() calls finish() and has nothing left to do, so it is a tail call. Right: addAfter() calls finish() but still has to add the text value after it, so it is not a tail call.return finish("direct")direct()hands offfinish()nothing left to doa tail callreturn "value " + finish("add")addAfter()waitsfinish()still has work afternot a tail call
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.

Is the call in tail position? Six ways to write a return
When you writeIs 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:

  1. The code must be in strict mode.
  2. 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.

Tail position or not?
  • return finish(order);
  • return 1 + finish(order);
  • return cached || loadFresh();
  • return loadFresh() || cached;
  • return ok ? sendA() : sendB();
  • return continue sendA();
Try it yourself
0 of 6 correct

Sort each line of code. Ask: does the function still have work to do after the call?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Proper tail calls in ES2015

RULE VS REALITY

ES2015 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.

Node only: tail recursion still runs out of stackJavaScript
"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)));
What the program prints (version numbers vary)Text
node v22.x
v8 12.4.x
plain-countdown RangeError
tail-sum RangeError
trampoline-countdown done
trampoline-sum 5000050000
Checked by a real run

The 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

SUPPORT

WebKit 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.stack less useful. (error.stack is 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.

Which engines do proper tail calls today
EngineDoes 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 appsDon't count on it.Assume they don't, unless the engine's own documentation says they do.
Why debugging matters here

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 THROUGH

Here 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.

sumTail(4) in two engines. Left, Node and Chrome: five frames pile up, from sumTail(4, 0) at the bottom to sumTail(0, 10) on top. Right, Safari: one frame is reused, and n and acc change from 4, 0 to 0, 10.Node and ChromesumTail(0, 10)sumTail(1, 9)sumTail(2, 7)sumTail(3, 4)sumTail(4, 0)a new frame for each call5 framesSafari4, 0 → 3, 4 → 2, 7→ 1, 9 → 0, 10sumTail(n, acc)the same frame, reused1 frame
Node and Chrome add a new frame for each call, so 5 frames pile up. Safari reuses one frame and only changes n and acc.
Step through sumTail(4): frames with and without PTC
Step 0 of 12Ready
Your turn: follow the blue line

Step through sumTail(4). The two frame counters are a model to help you learn. They don't look inside your browser's engine.

Running in
  1. script
Next: line 8
"use strict"; function sumTail(n, acc = 0) {  if (n === 0) return acc;  return sumTail(n - 1, acc + n);} 
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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

WORKAROUND

You 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.

Real-life analogyA relay runner handing over the baton

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
The trampoline loop calls a thunk. The thunk returns the next thunk to the loop. This repeats until a call gives back 10, which is not a function, so the loop stops after 5 hops.trampoline loopa thunk1. calls a thunk2. gets the next thunkone at a time10not a function, so stopafter 5 hops
The loop calls one thunk, and the thunk hands back the next one. When a call gives back 10, which is not a function, the loop stops.
Step through a trampoline: each thunk goes back to the loop
Step 0 of 21Ready
Your turn: follow the blue line

Watch a trampoline do recursive work with a loop. The code really computes the sum. The recording shows each step.

Running in
  1. script
Next: line 16
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);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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 RUNTIME

Now 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.

Run a real countdown in your browser
The code the playground runsJavaScript
"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);}
Result in your browsernot run yet
nothing run yetno result

Press Run comparison to try this browser. Press Reset to come back here.

Step 0 of 50000your browser
Pick a number from 0 to 50000.

Press Run comparison to run both versions in this browser. Nothing runs until you press it.

This runs real JavaScript in your browser and catches any error. Safari, Chrome, and Firefox may give different results.

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

PROPOSAL

A 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.
Proposed syntax only: this is not JavaScript todayJavaScript
function factorial(n, acc = 1) {  if (n <= 1) return acc;  return continue factorial(n - 1, acc * n);}
Where the proposal stands

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?

PRACTICAL

Most 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.
A recursive menu walk that may need its own stack for deep menusPop out in the code editor (opens in a new tab)JavaScript
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.

Ways to handle very deep work
ApproachHow it keeps the stack smallWhen to use it
Proper tail callsThe 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 stackRewrite 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.
TrampolineReturn 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 callsA 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.

Four tail-call names that are easy to mix up
NameWhat it meansWhat it does not mean
Tail callA 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 callThe 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 recursionA function that calls itself as a tail call.It is not safe from stack overflow in V8, Node, and most browsers today.
TrampolineA 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

Exercise 1 · Warm-upPredict the output when the call is not a tail call

Read the code. Type the two printed lines in order.

Starter codePop out in the code editor (opens in a new tab)JavaScript
"use strict";
function finish(label) {
  console.log("finish " + label);
  return label;
}
function notTail() {
  return "value " + finish("add");
}
console.log(notTail());

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upIs the right side a tail call?

    In return cached || finish(), is finish() in tail position?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeRead the Node result

      What error name does the strict tail-recursive countdown hit in Node 22?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeCount trampoline hops

        How many hops (turns of the loop) does the trampoline report for sumThunk(4)?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · PracticeFind the bug in the proposed syntax

          What is the TC39 status of the return continue proposal today?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          function factorial(n, acc = 1) {
            if (n <= 1) return acc;
            return continue factorial(n - 1, acc * n);
          }

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeUse it on a real app tree

            Name one workaround that works in every engine, for a deeply nested menu, comment thread, or file browser.

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              QUIZ

              First use the rules for tail position. Then use the facts about which engines do proper tail calls.

              Lesson quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What is a tail call?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does this snippet print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                "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.

              3. 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.

              4. Question 4 of 7Which call is in tail position, under the spec's rules for ||?

                Choose an answer to see the explanation.

              5. 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.

              6. Question 6 of 7What does the trampoline example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function 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.

              7. 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.

              CompleteFrontend Clear concepts. Working examples.