completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Advanced recursion

Master tail calls, trampolines, CPS, deep clone, and deep equal while keeping recursive JavaScript safe for real nested data.

By the end, you can
  • 01
    Spot a tail callTell when a call is the last thing a function does, and explain why Chrome and Node still need another plan for deep recursion.
  • 02
    Handle very deep recursionUse a trampoline or your own array stack when the input can be tens of thousands of steps deep.
  • 03
    Copy and compare nested data safelyDeep-clone and deep-compare nested objects, arrays, Dates, and Maps, even when the data points back to itself.

Recursion after the basics

The Recursion lesson covered the basics. A recursive function is a function that calls itself. It has two parts:

  • The base case stops the calls.
  • The recursive case calls the function again with a smaller input.

Each call that is still waiting for an answer keeps a stack frame. A stack frame is a small record of that call's variables, kept on the call stack. The call stack has a size limit. If too many calls wait at once, JavaScript throws RangeError: Maximum call stack size exceeded. This is called a stack overflow.

This lesson asks two new questions:

  • What if the input is 100,000 levels deep?
  • What if the data points back to itself?

Advanced recursion means choosing where the waiting work is kept. You will learn these ways:

  • Keep the waiting work in an argument, so nothing waits after the call. This is a tail call.
  • Return a small function for the next step, and let a loop run it. This is a trampoline.
  • Keep the waiting work in your own array, and use a loop. This is an explicit stack.
  • Pass a callback that says what to do with the answer. This is continuation-passing style.
  • Remember the objects you have already visited, so data that points back to itself doesn't trap you.
The main words
  • A tail call is a call that is the very last thing a function does. The function returns the call's result as it is.
  • A trampoline is a loop that keeps calling small functions until it gets back something that is not a function.
  • Continuation-passing style passes a callback that says what to do next with the answer.
  • A deep clone copies an object and everything inside it. A deep equal check compares two values and everything inside them. Both use a WeakMap to remember objects they have already seen.
Real-life analogyFolders inside folders

You are looking through folders inside folders on your computer. Each time you open a folder, you could say: "Open the next one, then come back here." Then you must remember every folder you still need to come back to.

Instead, you can write the folders you still need to open on a to-do list. You open one folder at a time and cross it off. If you put a sticker on each folder you open, you never open the same folder twice.

In real life: Open a folder inside another folder
In JavaScript: A recursive call works on a smaller part of the problem
In real life: Write the folders still to open on a to-do list
In JavaScript: Keep waiting work in an array, and loop over it
In real life: Put a sticker on each folder you open
In JavaScript: A WeakMap remembers objects already copied or compared
In real life: Never go back to a folder once you open the next one
In JavaScript: A tail call: nothing is left to do after the call

These ideas build on earlier lessons: stack and heap, closures, WeakMap and WeakSet, and structuredClone from messaging and cloning.

Tail calls: nothing waits after the call

STEP THROUGH

A call is in tail position when it is the very last thing a function does. The function returns the call's result as it is, with no more work. A call in tail position is called a tail call.

Compare the two factorial functions below. The factorial of 5 is 5 × 4 × 3 × 2 × 1, which is 120.

One call waits to multiply, one doesn'tPop out in the code editor (opens in a new tab)JavaScript
function factorial(n) {  if (n <= 1) return 1;  return n * factorial(n - 1);} function factorialTail(n, acc = 1) {  if (n <= 1) return acc;  return factorialTail(n - 1, n * acc);} console.log(factorial(5));console.log(factorialTail(5));

Line 3 is not a tail call. After factorial(n - 1) gives back its answer, line 3 still has to multiply it by n. So each call waits, and keeps its stack frame.

Line 8 is a tail call. factorialTail has a second parameter, acc. It is short for accumulator: a value that collects the answer as the calls go on. Line 8 multiplies first, n * acc, and passes the product to the next call. After that call, there is nothing left to do.

Lines 11 and 12 print 120 twice. Both functions give the same answer. Only the waiting work is different.

The table below explains four terms that sound alike. Read each row as: the term, what it means, and an example.

Four terms that sound alike
TermWhat it meansExample
Not a tail callMore work happens after the call gives back its answer.return n * factorial(n - 1); still has to multiply.
Tail callThe call is the last thing the function does.return factorialTail(n - 1, n * acc); has nothing left to do.
Proper tail callA rule in the standard: the engine reuses the stack frame for a tail call in strict mode.ES2015 has the rule. Of the big engines, only Safari's follows it.
TrampolineA loop in your own code that keeps calling the thunks it gets back.It works the same in every engine.

Step through factorialTail(5) below. Watch acc go from 1 to 5, then 20, then 60, then 120. The multiplication that used to wait now travels inside acc.

factorialTail(5) as five calls in a row: (5, 1), (4, 5), (3, 20), (2, 60), (1, 120). Each arrow multiplies acc by n. The last call returns 120.factorialTail(n, acc)(5, 1)(4, 5)(3, 20)(2, 60)(1, 120)×5×4×3×2acc starts at 1returns 120
Each call multiplies acc before it makes the next call. When n reaches 1, acc already holds 120, so nothing is left to do on the way back.
Step through factorialTail(5)
Step 0 of 16Ready
Your turn: follow the blue line

Step through a factorial with a tail call. Watch acc carry the multiplication, so nothing is left to do after the call on line 3.

Running in
  1. script
Next: line 6
function factorialTail(n, acc = 1) {  if (n <= 1) return acc;  return factorialTail(n - 1, n * acc);} 
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.
Most engines don't save stack space for tail calls

A JavaScript engine is the program inside a browser or Node that runs your code. The ES2015 standard says an engine should reuse the stack frame for a tail call in strict mode. This is called a proper tail call. But the engines don't all do it:

  • JavaScriptCore, the engine in Safari, does it.
  • V8, the engine in Chrome and Node, does not.
  • SpiderMonkey, the engine in Firefox, does not.

So write tail calls because they are easy to follow. Don't count on them to stop a stack overflow.

The next example throws an error in Chrome and Node on purpose. It uses strict mode and a tail call, but V8 still adds a stack frame for every call. The exact limit is different on each computer, so the example uses a large number.

A tail call that still overflows in V8Pop out in the code editor (opens in a new tab)JavaScript
"use strict";function dive(n) {  if (n === 0) return "done";  return dive(n - 1);} console.log(dive(100000));

Line 1 turns on strict mode. Line 4 calls dive again with a number one smaller. Line 7 starts at 100,000. In V8, the stack fills up long before n reaches 0. So it throws a RangeError instead of printing done.

Trampolines: return the next step instead of taking it

INTERACTIVE

A thunk is a function with no parameters that holds one step of work for later. For example, () => sumThunk(4, 5) is a thunk. Nothing happens until you call it.

A trampoline is a loop that keeps calling thunks until it gets back something that is not a function. In the code below, sumThunk adds the numbers from n down to 1. It doesn't call itself. It returns a thunk for the next step, and the loop calls that thunk. So the call for 4 never runs inside the call for 5. Each step starts fresh from the loop.

Step through the trampoline loop
Step 0 of 18Ready
Your turn: follow the blue line

A trampoline gets a thunk back from each step, instead of a deeper call. Step through the loop: each turn calls one thunk, and then the call stack is short again.

Running in
  1. script
Next: line 16
function trampoline(task) {  let current = task;  let steps = 0;  while (typeof current === "function") {    current = current();    steps = steps + 1;  }  return { value: current, steps };} function sumThunk(n, acc = 0) {  if (n === 0) return acc;  return () => sumThunk(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.

Read the loop line by line:

  1. Line 2: current starts as the first thunk.
  2. Line 3: steps counts the turns of the loop.
  3. Line 4: the loop goes on while current is still a function.
  4. Line 5: call exactly one thunk, and keep what it gives back in current.
  5. Line 6: add one to the turn count.
  6. Line 8: once current is a number, return it with the count.

Line 13 is where sumThunk returns a thunk instead of calling itself. Line 16 prints 15, which is 5 + 4 + 3 + 2 + 1.

The trampoline loop. On turns 1 to 5, calling the thunk gives back another thunk, so the loop goes again. On turn 6, calling the thunk gives back 15, so the loop stops.current = current()turns 1–5thunkthunkloop againturn 6thunk15stop
Each turn calls the thunk in current and keeps what it gives back. Turns 1 to 5 give back a new thunk; turn 6 gives back the number 15, so the loop stops.

Now try a much bigger number. Pick a size below. The loop still calls one thunk per turn, so the call stack never grows.

Run a trampoline with a big number
Add up the numbers with a trampolinePop out in the code editor (opens in a new tab)JavaScript
function trampoline(task) {  let current = task;  let steps = 0;  while (typeof current === "function") {    current = current();    steps = steps + 1;  }  return { value: current, steps };} function sumThunk(n, acc = 0) {  if (n === 0) return acc;  return () => sumThunk(n - 1, acc + n);} const result = trampoline(() => sumThunk(100000));console.log(result.value);console.log(result.steps > 100000);
Output100,000 input
value5,000,050,000
steps > inputtrue
Try it yourself
Choose how many steps to run

Only the number in the code changes. The work still runs as one thunk per loop turn.

The loop ran 100,001 turns and returned 5,000,050,000. It never needed 100,000 stack frames at once.

The value is the sum of every number from n down to 1. Even big inputs run fast, because each thunk finishes before the next one starts.
Why not use a trampoline every time?

A trampoline makes a new small function for every step. That costs time and memory. It also makes errors harder to read. The stack trace, the list of calls shown with an error, no longer shows the steps.

Use a trampoline when you want to keep the recursive shape of your code and the depth can be large. To walk a tree, an explicit stack (next section) is usually simpler and faster.

Turn recursion into a loop with your own stack

COMPARE

Often the safest rewrite is the simplest one. Keep the waiting work in an ordinary array, and loop until the array is empty. This array is called an explicit stack, because you can see it in your code.

The array lives in the heap, the big part of memory where objects and arrays are kept. The heap has far more room than the call stack. And the call stack stays one frame deep, because no function calls itself. This works well for a folder of files, a walk through a page's elements, or comment threads where users can nest replies as deep as they like.

First, here is a recursive version that counts the comments in a thread:

Count comments with recursionPop out in the code editor (opens in a new tab)JavaScript
const comments = {
  text: "Welcome",
  replies: [
    { text: "Thanks", replies: [{ text: "You are welcome", replies: [] }] },
    { text: "Helpful", replies: [] },
  ],
};

function countComments(comment) {
  return 1 + comment.replies.reduce((total, reply) => total + countComments(reply), 0);
}

console.log(countComments(comments));

Lines 1 to 7 make a comment with two replies. The first reply has a reply of its own. Line 10 counts the current comment as 1. Then reduce goes through each reply and adds that reply's own count. So the same small function works at every level. Line 13 prints 4, for the four comments.

If users write the replies, the thread can be deeper than the call stack allows. The panel below walks a tree of files with a loop and an array instead.

A loop with its own stack, and no recursive calls
Count files with a loopPop out in the code editor (opens in a new tab)JavaScript
const tree = {  name: "site",  children: [    { name: "index.html" },    { name: "assets", children: [      { name: "logo.svg" },      { name: "app.js" },    ] },    { name: "notes.txt" },  ],}; function countFilesIterative(root) {  const stack = [root];  let files = 0;  while (stack.length > 0) {    const node = stack.pop();    if (!node.children) {      files = files + 1;      continue;    }    for (const child of node.children) {      stack.push(child);    }  }  return files;} console.log(countFilesIterative(tree));
Outputfiles

4

Try it yourself
The loop keeps its own stack

The array named stack is ordinary data in the heap. The loop pushes children onto it and pops one item at a time, so the call stack stays one frame deep.

Use this when the input can be deeper than the call stack allows.

Read the loop line by line:

  1. Line 14: the array stack starts with the whole tree.
  2. Line 16: the loop runs while the array still has work in it.
  3. Line 17: pop() takes the last item off the end of the array.
  4. Lines 18 to 20: an item with no children is a file, so add one to files.
  5. Lines 22 and 23: an item with children is a folder, so push each child onto the array for later.
  6. Line 29: print 4, for index.html, logo.svg, app.js, and notes.txt.
The stack array holds three cards: index.html at the bottom, assets in the middle, and notes.txt on top. pop() takes notes.txt off the top. It is a file, so files becomes 1.stacknotes.txtassetsindex.htmlthe top card leaves firstpop()notes.txta filefiles = 1
After the loop opens site, the array holds its three children. pop() takes the last one pushed, notes.txt. It is a file, so files becomes 1.
Which tool does this use?
  • `return factorialTail(n - 1, n * acc);`
  • `return () => sumThunk(n - 1, acc + n);`
  • `const stack = [root]; while (stack.length) ...`
  • `sumCps(n - 1, (small) => done(small + n))`
  • Walk 80,000 nested page elements while importing content
  • Build a parser where each step returns the next step as a function
Try it yourself
0 of 6 correct

Put each card under the tool it uses. Each answer explains why.

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

Continuation-passing style: pass what to do next

CONCEPT

A continuation is a callback that says what to do with an answer. In continuation-passing style (CPS for short), a function doesn't return its answer in the usual way. It receives a callback, often named done, and calls done(answer) instead.

Add 1 to 4 by passing callbacksPop out in the code editor (opens in a new tab)JavaScript
function sumCps(n, done) {  if (n === 0) return done(0);  return sumCps(n - 1, (smaller) => {    return done(smaller + n);  });} sumCps(4, (answer) => console.log(answer));

In plain words, line by line:

  1. Line 1: sumCps takes a number n and a callback done.
  2. Line 2: the base case. When n is 0, call done(0).
  3. Lines 3 and 4: call sumCps with n - 1 and a new callback. When the smaller sum arrives as smaller, the new callback adds n and passes the total to the older done.
  4. Line 8: start with 4 and a callback that prints the answer. It prints 10, which is 1 + 2 + 3 + 4.

This is the same idea as callbacks and, later, async code: you hand another piece of code the next thing to do.

CPS alone doesn't prevent a stack overflow

This example still calls sumCps from inside sumCps. So a very large n can still overflow the stack in V8. CPS is safe for deep work only when a trampoline runs the steps, or when each callback runs later, for example after a timer.

Functions that call each other, and saving answers

DEEPEN

Mutual recursion means two or more functions call each other in a circle. Each function has its own base case. Otherwise, it passes a smaller input to the other function.

Two functions that call each otherPop out in the code editor (opens in a new tab)JavaScript
function isEven(n) {
  if (n === 0) return true;
  return isOdd(n - 1);
}

function isOdd(n) {
  if (n === 0) return false;
  return isEven(n - 1);
}

console.log(isEven(4), isOdd(5));

isEven(4) calls isOdd(3). That calls isEven(2), then isOdd(1), then isEven(0), which returns true. Line 11 prints true true: 4 is even, and 5 is odd. Each call still adds a stack frame in V8, so a very large number can overflow. Use a loop when you don't control how big the number gets.

Memoization means saving a function's answer for an input, so a later call with the same input can reuse it. The saved answers are called a cache. It helps when a recursive function asks for the same smaller problem many times.

The Fibonacci numbers are a good example. Each one is the sum of the two before it: 0, 1, 1, 2, 3, 5, 8, 13, 21. To work out fib(8), you need fib(7) and fib(6). But fib(7) needs fib(6) too. Without a cache, the same numbers get worked out again and again.

Save Fibonacci answers in a MapPop out in the code editor (opens in a new tab)JavaScript
const cache = new Map();
let calls = 0;

function fib(n) {
  calls = calls + 1;
  if (cache.has(n)) return cache.get(n);
  if (n < 2) return n;
  const answer = fib(n - 1) + fib(n - 2);
  cache.set(n, answer);
  return answer;
}

console.log(fib(8));
console.log(calls);

In plain words, line by line:

  1. Line 1: cache is a Map from an input to its answer.
  2. Line 2: calls counts how many times fib runs.
  3. Line 6: if the answer for n is already saved, return it.
  4. Line 7: the base case. fib(0) is 0, and fib(1) is 1.
  5. Lines 8 and 9: work out the answer, then save it in the cache.
  6. Line 13 prints 21, and line 14 prints 15.

Fifteen calls is far fewer than the 67 calls the same function makes without a cache. A cache key must describe the whole input. If the input can change, you also need a rule for when to throw saved answers away.

A cache is not the same as the seen map later in this lesson. A cache reuses an answer. A seen map only remembers which objects you already visited, so you don't go round the same loop in the data forever.

Only save answers that never change

Don't cache a function whose answer depends on the current time, random numbers, or values that change elsewhere in the program, unless the key includes them. Otherwise the cache can hand back an old, wrong answer that looks correct.

Recursive data: deep clone with a WeakMap

INTERACTIVE

Recursive data is data that holds smaller pieces with the same shape. For example:

  • An object can hold an array of objects.
  • A Map can hold objects as its values.
  • A comment can hold replies, which hold more replies.

A deep clone copies an object and everything inside it. The hard part is a cycle: data that points back to itself. After profile.self = profile, the self property points to the same object it lives in. A clone that just follows every property would go round that loop forever.

The fix is a WeakMap. A WeakMap is like a Map whose keys are objects. It doesn't stop those objects from being cleared out of memory when nothing else uses them. Here it is called seen, and it links each original object to its copy. A safe deep clone works in this order:

  1. If the value is a primitive, such as a number or a string, return it as it is.
  2. If seen already has this object, return the copy saved there.
  3. Make a new, empty copy.
  4. Save the pair "original, copy" in seen.
  5. Only then, copy the children.

Step 4 must come before step 5. Then, when a child points back to the original, the clone finds it in seen and uses the copy.

Left: the profile cart has a self card that points back to profile. deepClone makes a copy cart. seen remembers profile goes with copy, so the copy's self card points back to copy.profile.self = profileselfprofiledeepCloneseen: profile → copycopy.self === copyselfcopy
Before it copies any children, deepClone saves the pair profile → copy in seen. When it reaches self, it finds profile in seen and uses the copy.

The panel below runs the full helper. Use the buttons to compare it with structuredClone, the clone function built into browsers and Node.

Deep clone: your own helper or structuredClone
Deep clone with a WeakMapPop out in the code editor (opens in a new tab)JavaScript
function deepClone(value, seen = new WeakMap()) {  if (Object(value) !== value || typeof value === "function") return value;  if (seen.has(value)) return seen.get(value);  if (value instanceof Date) return new Date(value.getTime());  if (value instanceof Map) {    const clone = new Map();    seen.set(value, clone);    for (const [key, entry] of value) {      clone.set(deepClone(key, seen), deepClone(entry, seen));    }    return clone;  }  if (Array.isArray(value)) {    const clone = [];    seen.set(value, clone);    for (const item of value) clone.push(deepClone(item, seen));    return clone;  }  const clone = Object.create(Object.getPrototypeOf(value));  seen.set(value, clone);  for (const key of Reflect.ownKeys(value)) {    clone[key] = deepClone(value[key], seen);  }  return clone;} const profile = {  name: "Ada",  dates: [new Date("2020-01-02T00:00:00Z")],  meta: new Map([["visits", 3]]),};profile.self = profile; const copy = deepClone(profile);console.log(copy !== profile);console.log(copy.dates[0] instanceof Date);console.log(copy.meta.get("visits"));console.log(copy.self === copy);
Outputcustom
  1. true
  2. true
  3. 3
  4. true
Try it yourself

The custom clone keeps Dates, Maps, arrays, and the loop in the data. It remembers each original object and its copy in a WeakMap.

Both examples run real JavaScript. The custom helper's rule is to keep the same function, not to copy it.

In the custom clone, line by line:

  1. Line 2: a primitive, or a function, comes back as it is.
  2. Line 3: an object already in seen gives back its saved copy.
  3. Line 4: a Date gets a new Date with the same time.
  4. Lines 7, 15, and 20: a Map, an array, or a plain object saves its new copy in seen before its children are copied.
  5. Line 32: profile.self = profile makes the cycle.
  6. Lines 35 to 38 print true, true, 3, and true. The last one shows the copy's self points to the copy.

The table below compares the two ways to clone. Read each row as: the kind of value, what the custom clone does, and what structuredClone does.

How each kind of value gets cloned
Kind of valueWhat the custom clone doesWhat structuredClone does
Nested arrays and objectsCopies each level, and uses a seen map for loops in the data.Copies them too, loops included.
DateMakes a new Date with the same time: new Date(value.getTime()).Copies it as a Date too.
MapSaves the new Map in seen, then copies each key and value.Copies it, and keeps the order of its entries.
FunctionsKeeps the same function, because that is this helper's rule.Throws a DataCloneError.
Page elements (DOM nodes)Not meant for these. Use node.cloneNode(true) instead.Can't copy them. It throws a DataCloneError.
Class objectsKeeps the class, so its methods still work.Gives back a plain object, without the class methods.

Use structuredClone for plain data that it supports: arrays, objects, Dates, Maps, Sets, ArrayBuffers, and cycles. Write your own helper only when you need a special rule, for example for functions or class methods. Elements on a page are not plain data. Copy them with node.cloneNode(true) instead.

Deep equal: you choose the rules

INTERACTIVE

A deep equal helper walks through two values side by side. It checks that everything inside them matches. Before you write one, you must decide some rules. A set of rules like this is called a policy. You must answer questions like these:

  • Should NaN ("not a number") count as equal to NaN?
  • Should -0 count as equal to 0?
  • Does the order of an object's keys matter?
  • Does the order of an array's items matter?
  • What happens when both values point back to themselves?
Try the deep-equal rules
Deep equal that remembers pairsPop out in the code editor (opens in a new tab)JavaScript
function deepEqual(a, b, seen = new WeakMap()) {  if (Object.is(a, b)) return true;  if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;  const known = seen.get(a);  if (known?.has(b)) return true;  if (!known) seen.set(a, new WeakSet([b]));  else known.add(b);  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;  if (a instanceof Date) return Object.is(a.getTime(), b.getTime());  if (Array.isArray(a)) {    if (!Array.isArray(b) || a.length !== b.length) return false;    return a.every((item, index) => deepEqual(item, b[index], seen));  }  if (a instanceof Map) {    if (!(b instanceof Map) || a.size !== b.size) return false;    const aEntries = [...a.entries()];    const bEntries = [...b.entries()];    return aEntries.every(([key, value], index) => {      return deepEqual(key, bEntries[index][0], seen) && deepEqual(value, bEntries[index][1], seen);    });  }  const aKeys = Reflect.ownKeys(a);  const bKeys = Reflect.ownKeys(b);  if (aKeys.length !== bKeys.length) return false;  const bKeySet = new Set(bKeys);  for (const key of aKeys) {    if (!bKeySet.has(key) || !deepEqual(a[key], b[key], seen)) return false;  }  return true;} const left = { a: NaN, b: -0, nested: [1, { x: 2 }] };const right = { nested: [1, { x: 2 }], b: 0, a: NaN };console.log(deepEqual(left, right));right.b = -0;console.log(deepEqual(left, right));left.self = left;right.self = right;console.log(deepEqual(left, right));console.log(deepEqual([1, 2], [2, 1]));
Resultsminus-zero
first checkfalse
after right.b = -0true
Try it yourself

This helper uses Object.is, so NaN equals NaN, but -0 and 0 are different. That is why the first check is false: b is -0 on the left and 0 on the right.

Deep equal has no single right answer: you choose the rules. Write tests that show your rules, so nobody changes them by accident.

This lesson's helper uses these rules:

  • Line 2 uses Object.is. It works like ===, except in two cases: it says NaN equals NaN, and it says -0 is different from 0.
  • Object keys are matched by name, so their order doesn't matter.
  • Array items are matched by position, so their order does matter.
  • Lines 4 to 7 handle cycles. A WeakMap links each left object to a WeakSet, a set of the right objects it was already compared with. If the same pair comes up again, the helper says they are equal instead of going round the loop forever.

Where you'll use advanced recursion

You'll need these tools whenever front-end code walks through nested data and you don't know how deep it goes:

  • Walking a folder of files that a user drags onto the page, to show the totals.
  • Walking a tree of page elements in tests or in a text editor, when you can't trust its depth.
  • Showing comment threads where replies hold more replies.
  • Tidying up deeply nested JSON that a server sends back.
  • Copying data before you send it to a worker or an iframe.
  • Comparing two snapshots of a form, so a different key order doesn't look like a change.

The table below helps you pick a tool. Read each row as: the tool, what it does, and when it helps.

Picking a tool
ToolWhat it doesWhen it helps
Tail callPuts the call last, so nothing waits after it.It makes code easy to follow. Most engines still add a stack frame for each call.
TrampolineReturns a thunk for the next step, and a loop calls the thunks.It keeps the recursive shape of your code when the depth can be large.
Explicit stackKeeps the waiting items in an array, and loops.It is the best everyday choice for huge trees, page elements, and folders of files.
CPSPasses a callback that says what to do next.It helps you understand callbacks and async code. On its own, it doesn't stop a stack overflow.
A good habit

If the depth comes from users, files, a content system, the page, or the network, expect it to be deeper than your test data. Use a trampoline or an explicit stack, and add a test with a very deep input.

Common misconceptions

"A tail call in JavaScript is always safe."

Not in Chrome, Node, or Firefox. A tail call is easy to follow, but those engines still add a stack frame for every call.

"A trampoline is a faster recursive function."

A trampoline swaps stack frames for loop turns, and it makes a new function for every step. It stops stack overflows. It doesn't make the code faster.

"CPS means the code runs later."

CPS only means you pass a callback for the next step. The callback can run right away, as in this lesson's example, or later, as with browser events and promises.

"structuredClone replaces every custom deep clone."

It is great for plain data. But it throws on functions and page elements, and it gives back a class object without its methods. A custom clone lets you choose your own rules.

"Deep equal has one right answer."

Teams choose their own rules for -0, class objects, Maps, Sets, arrays with gaps, and cycles. Write tests that show which rules you chose.

The table below lists four more wrong guesses. Read each row as: the guess, what is really true, and what to do instead.

Wrong guesses that sound reasonable
The guessWhat is trueWhat to do
A tail call always saves stack space.A tail call is about where the call sits in the code. Saving stack space is up to the engine.Expect no saving outside Safari.
A returned thunk is the answer.A thunk is one step of work, waiting to run.Run it with a trampoline loop.
A WeakMap is only there for speed.It keeps one copy for each object, and it stops loops in the data.Save the pair before you clone or compare the children.
Order matters the same way in objects and arrays.Key order in an object usually doesn't matter. Item order in an array does.Write down your rule for order.

Practice advanced recursion

5 EXERCISES
Exercise 1 · Warm-upPredict the factorial with acc

Read the factorial with a tail call, and type the number it prints.

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

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

    Exercise 2 · PracticeShow that a trampoline can run 100,000 steps

    Predict whether the last line prints true or false.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function trampoline(task) {
      let current = task;
      let steps = 0;
      while (typeof current === "function") {
        current = current();
        steps = steps + 1;
      }
      return { value: current, steps };
    }
    
    function sumThunk(n, acc = 0) {
      if (n === 0) return acc;
      return () => sumThunk(n - 1, acc + n);
    }
    
    const result = trampoline(() => sumThunk(100000));
    console.log(result.steps > 100000);

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

      Exercise 3 · PracticeFix the endless loop in a deep clone

      This deep clone has no seen map, so it never stops on data that points back to itself. Fix it. What should the fixed check print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      function deepClone(value) {
        if (Object(value) !== value) return value;
        const clone = Array.isArray(value) ? [] : {};
        for (const key of Reflect.ownKeys(value)) {
          clone[key] = deepClone(value[key]);
        }
        return clone;
      }

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

        Exercise 4 · PracticeName the deep-equal rules

        Predict the two values that the rule check prints.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        function deepEqual(a, b, seen = new WeakMap()) {
          if (Object.is(a, b)) return true;
          if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
          const known = seen.get(a);
          if (known?.has(b)) return true;
          if (!known) seen.set(a, new WeakSet([b]));
          else known.add(b);
          if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
          if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
          if (Array.isArray(a)) {
            if (!Array.isArray(b) || a.length !== b.length) return false;
            return a.every((item, index) => deepEqual(item, b[index], seen));
          }
          if (a instanceof Map) {
            if (!(b instanceof Map) || a.size !== b.size) return false;
            const aEntries = [...a.entries()];
            const bEntries = [...b.entries()];
            return aEntries.every(([key, value], index) => {
              return deepEqual(key, bEntries[index][0], seen) && deepEqual(value, bEntries[index][1], seen);
            });
          }
          const aKeys = Reflect.ownKeys(a);
          const bKeys = Reflect.ownKeys(b);
          if (aKeys.length !== bKeys.length) return false;
          const bKeySet = new Set(bKeys);
          for (const key of aKeys) {
            if (!bKeySet.has(key) || !deepEqual(a[key], b[key], seen)) return false;
          }
          return true;
        }
        
        console.log(deepEqual(NaN, NaN) + "," + deepEqual(-0, 0));

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

          Exercise 5 · ChallengeChoose a safe tool for a website

          A social site loads comment threads that users write. Some threads are tens of thousands of replies deep. Which tool should the page use first to walk them: a tail call, a trampoline, CPS, or an explicit stack?

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

            Check your understanding

            8 QUESTIONS

            For each code question, ask three things. Is any work left after the call? Is a thunk still waiting for the loop? Which equality rules does the helper use? Each answer explains why it is right or wrong.

            Lesson quiz · 8 questionsScore: first tries count
            1. Question 1 of 8Which line has a call in tail position?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does the tail and non-tail pair print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function nonTail(n) {
                if (n === 0) return 0;
                return 1 + nonTail(n - 1);
              }
              function tail(n, acc = 0) {
                if (n === 0) return acc;
                return tail(n - 1, acc + 1);
              }
              console.log(nonTail(3), tail(3));

              Choose an answer to see the explanation.

            3. Question 3 of 8Which sentence about proper tail calls is true today?

              Choose an answer to see the explanation.

            4. Question 4 of 8What does the small trampoline print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function trampoline(task) {
                let current = task;
                let steps = 0;
                while (typeof current === "function") {
                  current = current();
                  steps = steps + 1;
                }
                return { value: current, steps };
              }
              
              function sumThunk(n, acc = 0) {
                if (n === 0) return acc;
                return () => sumThunk(n - 1, acc + n);
              }
              
              console.log(trampoline(() => sumThunk(3)).value);

              Choose an answer to see the explanation.

            5. Question 5 of 8What does the CPS example print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function sumCps(n, done) {
                if (n === 0) return done(0);
                return sumCps(n - 1, (smaller) => {
                  return done(smaller + n);
                });
              }
              
              sumCps(4, (answer) => console.log(answer));

              Choose an answer to see the explanation.

            6. Question 6 of 8What does the cycle-aware clone check print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function deepClone(value, seen = new WeakMap()) {
                if (Object(value) !== value || typeof value === "function") return value;
                if (seen.has(value)) return seen.get(value);
                if (value instanceof Date) return new Date(value.getTime());
                if (value instanceof Map) {
                  const clone = new Map();
                  seen.set(value, clone);
                  for (const [key, entry] of value) {
                    clone.set(deepClone(key, seen), deepClone(entry, seen));
                  }
                  return clone;
                }
                if (Array.isArray(value)) {
                  const clone = [];
                  seen.set(value, clone);
                  for (const item of value) clone.push(deepClone(item, seen));
                  return clone;
                }
                const clone = Object.create(Object.getPrototypeOf(value));
                seen.set(value, clone);
                for (const key of Reflect.ownKeys(value)) {
                  clone[key] = deepClone(value[key], seen);
                }
                return clone;
              }
              
              const item = { name: "node" };
              item.self = item;
              const copy = deepClone(item);
              console.log(copy !== item && copy.self === copy);

              Choose an answer to see the explanation.

            7. Question 7 of 8What does the deep-equal policy snippet print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function deepEqual(a, b, seen = new WeakMap()) {
                if (Object.is(a, b)) return true;
                if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
                const known = seen.get(a);
                if (known?.has(b)) return true;
                if (!known) seen.set(a, new WeakSet([b]));
                else known.add(b);
                if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
                if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
                if (Array.isArray(a)) {
                  if (!Array.isArray(b) || a.length !== b.length) return false;
                  return a.every((item, index) => deepEqual(item, b[index], seen));
                }
                if (a instanceof Map) {
                  if (!(b instanceof Map) || a.size !== b.size) return false;
                  const aEntries = [...a.entries()];
                  const bEntries = [...b.entries()];
                  return aEntries.every(([key, value], index) => {
                    return deepEqual(key, bEntries[index][0], seen) && deepEqual(value, bEntries[index][1], seen);
                  });
                }
                const aKeys = Reflect.ownKeys(a);
                const bKeys = Reflect.ownKeys(b);
                if (aKeys.length !== bKeys.length) return false;
                const bKeySet = new Set(bKeys);
                for (const key of aKeys) {
                  if (!bKeySet.has(key) || !deepEqual(a[key], b[key], seen)) return false;
                }
                return true;
              }
              
              console.log(deepEqual({ a: NaN }, { a: NaN }));
              console.log(deepEqual(-0, 0));

              Choose an answer to see the explanation.

            8. Question 8 of 8What happens in Chrome or Node when this strict tail call goes 100,000 calls deep?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              "use strict";
              function dive(n) {
                if (n === 0) return "done";
                return dive(n - 1);
              }
              
              console.log(dive(100000));

              Choose an answer to see the explanation.

            Key takeaways

            • A tail call is the last thing a function does. But most JavaScript engines still add a stack frame for it.
            • A trampoline stops stack overflows: each step returns a thunk, and a loop calls the thunks one at a time.
            • An explicit stack, an array plus a loop, is often the clearest way to walk a tree of unknown depth.
            • CPS passes a callback that says what to do next. It is the same idea as callbacks and async code.
            • Deep clone and deep equal need a seen map, usually a WeakMap, so data that points back to itself doesn't trap them.

            Remember the one-liner.
            Advanced recursion is about choosing where the next step waits: on the call stack, in a thunk, in an array, or in a callback.

            Coming next: Transducers, functors & monads. There, you will work with values that you can map over and chain, step by step.

            CompleteFrontend Clear concepts. Working examples.