cf.completefrontendCode editorOpen lab
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
    Identify real tail positionsTell why return f(x) is a tail call, why return 1 + f(x) is not, and why the right side of ||, &&, and ?? can be tail position.
  • 02
    Explain ES2015 PTCDescribe strict-mode proper tail calls as a semantic stack guarantee, then name the engine-support and debugging trade-offs.
  • 03
    Choose 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?

Definition

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.

Real-life analogyA to-do list that never grows

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 RULES

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

Tail position versus work after the callPop 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));

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.

Tail-position checklist
Code shapeClassificationReason
return f(x);Tail positionThe caller immediately returns the callee's result.
return 1 + f(x);Not tail positionThe caller still has to add 1 after f returns.
return f(x) || fallback;Not tail positionThe 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 runsThe spec's tail-position rule recurses into the right operand of ||.
return ok ? f(x) : g(x);Tail position for both callsEach branch result is returned directly.
const value = f(x); return value;Not tail positionThe 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.

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 source shape. Focus on whether the caller still has work 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

SPEC VS ENGINE

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

Node-only proof: V8 does not perform PTC hereJavaScript
"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)));
Shape of the verified outputText
node v22.x
v8 12.4.x
plain-countdown RangeError
tail-sum RangeError
trampoline-countdown done
trampoline-sum 5000050000
Verified runtime fact

The 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

SUPPORT

WebKit'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 support you should assume today
Engine familyPTC statusPractical note
JavaScriptCore / SafariShips ES2015 proper tail callsWebKit documents PTC and uses debugging machinery called ShadowChicken to reconstruct useful stacks while debugging.
V8 / Chrome / NodeDoes not ship PTC todayV8 implemented and staged ES2015 PTC, then favored explicit syntax because elided frames hurt debugging and always-on shadow stacks cost performance.
SpiderMonkey / FirefoxDoes not ship PTC todayMozilla co-championed syntactic tail calls as an explicit opt-in instead of silent frame elision for existing code.
Hermes, QuickJS, most embedded enginesDo not rely on PTCTreat PTC as unavailable unless the engine's own documentation says otherwise.
Why debugging changes the design

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 THROUGH

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

Tail-recursive accumulator: frames with and without PTC
Step 0 of 12Ready
Your turn: follow the blue line

Step through a tail-recursive accumulator. The side-by-side frame counters are a teaching model; they do not inspect your browser's engine.

Running in
  1. script
Next: line 8
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
"use strict"; function sumTail(n, acc = 0) {  if (n === 0) return acc;  return sumTail(n - 1, acc + n);} 
CallStoreChangeResultRun = next line. 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 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

WORKAROUND

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

Real-life analogyA relay runner handing over the baton

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.

Trampoline walkthrough: thunks return to the loop
Step 0 of 21Ready
Your turn: follow the blue line

Watch a trampoline turn recursive intentions into loop iterations. The code really computes the sum; the recording simply exposes each step.

Running in
  1. script
Next: line 16
Click the blue line to take the next stepPop 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(result.value);console.log(result.hops);
CallStoreChangeResultRun = next line. 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 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 RUNTIME

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

Run a real countdown in your browser
Browser comparison sourceJavaScript
"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);}
Runtime resultnot run yet
waiting for your runno result yet

Press Run comparison to measure this browser, then Reset to return here.

Step 0 of 50000your browser
Values are clamped between 0 and 50000.

Press Run comparison to try the plain recursive call and trampoline in this browser. No engine-dependent result is computed during page render.

This runs real JavaScript in your browser and catches errors. It does not inspect engine internals; Safari, Chromium, and Firefox may differ.

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

PROPOSAL

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

Inactive proposal syntax, not current JavaScriptJavaScript
function factorial(n, acc = 1) {  if (n <= 1) return acc;  return continue factorial(n - 1, acc * n);}
Proposal status

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?

PRACTICAL

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

A recursive menu traversal that may need an explicit stack for deep dataPop 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 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.

Stack-safe choices for deep work
ApproachHow it avoids stack growthWhen to use it
Proper tail callsEngine reuses or discards the caller frame for a strict tail-position call.Great when available, but not portable across mainstream engines.
Loop or explicit stackRewrite the recursion as while, for, or your own array of work.Most predictable production choice for deep traversals.
TrampolineReturn 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 callsA 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.

Similar tail-call ideas that are easy to mix up
IdeaWhat it meansWhat it does not mean
Tail callA source-code position: the call's result is returned directly.It does not promise stack safety by itself.
Proper tail callAn 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 recursionA recursive call that is also a tail call.It is still stack-recursive in V8, Node, and most browsers today.
TrampolineA library pattern that moves recursion into a loop of thunks.It changes your code shape; it is not the engine doing PTC.

Practice exercises

Exercise 1 · Warm-upPredict the non-tail output

Read the code and type the two console 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-upClassify the right operand

    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 proof

      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 loop hops 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 proposed syntax

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

          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 · ChallengeApply it to a real app tree

            Name one portable workaround you could use for a deeply nested menu, comment tree, or file browser.

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

              Check your understanding

              QUIZ

              Use the source-position rules first, then the engine-support facts.

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

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

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

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

              CompleteFrontend Clear concepts. Working examples.