Advanced recursion
Master tail calls, trampolines, CPS, deep clone, and deep equal while keeping recursive JavaScript safe for real nested data.
- 01Identify tail positionTrace accumulator recursion and explain why portable JavaScript still needs a stack-safe fallback.
- 02Flatten deep recursionUse trampolines or explicit stacks when the input can be tens of thousands of steps deep.
- 03Walk recursive data safelyDeep-clone and deep-compare nested objects, arrays, Dates, Maps, and cycles with honest policies.
Recursion after the basics
The beginner Recursion lesson taught the base case, the recursive case, and why the call stack grows while calls wait. This lesson starts from there. We will not repeat nesting dolls and simple factorial. Instead, we will ask: what if the input is 100,000 levels deep, or the data points back to itself?
Advanced recursion means controlling the shape of recursive work. Sometimes you rewrite the function so the recursive call is the final action. Sometimes you return a thunk and let a loop run it. Sometimes you stop using recursive calls entirely and keep your own stack in an array. For recursive data, you also remember objects you have already seen.
A tail call is a call in the final position of a function branch. A trampoline is a loop that repeatedly runs returned thunks. Continuation-passing style makes “what happens next” an explicit callback. Deep clone and deep equal use recursion over data, plus a WeakMap, so cycles do not trap them forever.
Imagine opening folders inside folders on your computer. If each folder says “open the next one, then come back,” the return list grows. A list can hold the next folders explicitly instead.
- In real life: Open a folder inside another folder
- In JavaScript: A recursive step reaches a smaller problem
- In real life: Keep folder names in a list
- In JavaScript: A trampoline returns a thunk, or a loop uses a stack
- In real life: Mark folders already opened
- In JavaScript: A WeakMap marks objects already cloned or compared
- In real life: No folder waits after the next one
- In JavaScript: A tail call has no work after the call returns
Where the analogy stops: Folders do not point back to themselves. JavaScript data can, so cycles need a seen map.
You will connect these ideas to stack and heap, closures, WeakMap and WeakSet, and messaging and cloning with structuredClone.
Tail calls: when nothing waits after the call
STEP THROUGHA call is in tail position when its result is returned directly. Compare the two factorials below. Line 3 in the first function is not tail position because n * still waits for the recursive answer. Line 8 in the accumulator version is tail position because the next call receives the updated product and no multiply remains.
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));| Term | Meaning | Example |
|---|---|---|
| Non-tail recursion | The recursive call is used by more work after it returns. | return n * factorial(n - 1); waits to multiply. |
| Tail recursion | The recursive call is the final action in that branch. | return factorialTail(n - 1, n * acc); has no pending multiply. |
| Proper tail call | A spec feature that can reuse a frame for a strict-mode tail call. | ES2015 defines it; Safari's JavaScriptCore implements it. |
| Trampoline | A user-land loop that repeatedly calls returned thunks. | Portable in V8, SpiderMonkey, and JavaScriptCore. |
Step through the accumulator version. Watch acc become 1, then 5, then 20, then 60, then 120. That is the pending multiplication moved into an argument.
Step through a tail-recursive factorial. Watch acc carry the waiting multiplication so line 3 has no work left after the recursive call.
script
function factorialTail(n, acc = 1) { if (n <= 1) return acc; return factorialTail(n - 1, n * acc);} ES2015 specifies proper tail calls for strict-mode JavaScript, but support is uneven. Current web research and engine behavior agree: Safari’s JavaScriptCore implements proper tail calls; V8, used by Chrome and Node, does not; SpiderMonkey, used by Firefox, does not. So write tail-recursive JavaScript for clarity, not as a cross-browser stack guarantee.
The next snippet deliberately throws in Chrome and Node. It is tail-recursive and strict, but V8 still grows the stack. The exact depth varies, so the example uses a large number and asks only for the error, not for a magic limit.
"use strict";function dive(n) { if (n === 0) return "done"; return dive(n - 1);} console.log(dive(100000));Trampolines: return the next jump instead of taking it
INTERACTIVEA thunk is a function with no arguments that delays work. A trampoline is a loop that keeps calling thunks until the result is not a function. Instead of nesting sumThunk(5) inside sumThunk(4) inside sumThunk(3), each call returns the next thunk to the loop.
A trampoline turns recursive steps into returned thunks. Step through the loop: each turn calls one thunk and then the stack is flat again.
script
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);} Read the loop line by line: current starts as a function, line 4 asks whether it is still a function, line 5 calls exactly one thunk, and line 6 counts that turn. When a number appears, line 8 returns it.
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);5,000,050,000trueThe trampoline finished 100,001 loop turns and returned 5,000,050,000 without asking JavaScript for 100,000 nested stack frames.
A trampoline allocates many small functions and hides the natural stack trace. Use it when you want a recursive functional shape and the depth can be large. For straightforward tree walking, an explicit stack is usually simpler and faster.
Convert recursion to an explicit stack
COMPAREThe safest production rewrite is often not clever. Put pending work in an array, then loop until that array is empty. The heap holds the array; the JavaScript call stack stays one frame deep. This is exactly what you want for a file tree, a DOM walk, a sitemap import, or user-generated comment threads with unknown depth.
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));4
The array named stack is ordinary heap data. The loop pushes children onto it and pops one node at a time, so the JavaScript call stack stays flat.
`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 DOM-like nodes in a CMS import
- Build a parser where each step returns the next suspended step
Sort each snippet or scenario by the strategy it uses. The answers explain the trade-off.
Continuation-passing style: pass “what to do next”
CONCEPTIn continuation-passing style, a function does not return its final answer to its caller in the usual way. It receives a callback, often named done or cont, and calls that callback with the answer. The callback is the continuation: the rest of the program written as a value.
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));Line 2 says the base case should call done(0). Line 3 passes a new continuation to the smaller problem. That continuation says: when the smaller sum arrives, add the current n, then call the older continuation. This is the same mental model behind callbacks and later async code: give another piece of code the next thing to do.
This direct CPS example still calls sumCps recursively, so a huge n can overflow in V8. CPS becomes stack-safe when paired with a trampoline or when the continuation is resumed asynchronously by the host.
Recursive data: deep clone with a WeakMap
INTERACTIVERecursive data contains smaller values with the same shape. An object can contain an array of objects; a Map can point to objects; a comment can contain replies that contain replies. The challenge is cycles: profile.self = profile points back to the object you started from.
A safe deep clone handles primitives directly, creates the destination container early, stores the source-to-copy pair in a WeakMap, then clones children. The early store is what makes cycles work.
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);truetrue3true
The custom clone preserves Dates, Maps, arrays, plain object links, and the cycle by remembering source objects in a WeakMap.
| Value | Custom clone | structuredClone honesty |
|---|---|---|
| Nested arrays and objects | Custom clone can copy recursively; structuredClone handles plain structured data too. | Use a seen map for cycles. |
| Date | Copy with new Date(value.getTime()) or let structuredClone handle it. | Do not turn it into a string unless that is your API. |
| Map | Clone keys and values recursively after placing the new Map in seen. | Map insertion order is part of its data. |
| Functions | A custom helper may keep the same reference by policy. | structuredClone throws DataCloneError. |
| DOM nodes | Use DOM APIs such as node.cloneNode(true) instead. | structuredClone does not clone live DOM nodes. |
| Class prototypes | A custom helper can preserve prototypes; descriptors still need extra work. | structuredClone returns ordinary data, not your class methods. |
Prefer structuredClone for plain structured data that the platform supports: arrays, objects, Dates, Maps, Sets, ArrayBuffers, and cycles. Reach for a custom helper when you need a specific policy for functions, prototypes, descriptors, or values the structured clone algorithm rejects. DOM nodes are live host objects; clone them with DOM APIs, not this data helper.
Deep equal: equality is a policy
INTERACTIVEA deep equal helper walks two values together. It needs to answer policy questions before it touches recursion: should NaN equal NaN? Should -0 equal 0? Does object key order matter? Are arrays ordered? What happens when both sides point back to themselves?
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]));falsetrueThis helper uses Object.is, so NaN equals NaN, but -0 and 0 are different by policy.
The helper in this lesson uses Object.is, so NaN matches itself and -0 remains different from 0. Object property order does not matter: keys are matched by identity and name. Array order does matter because index position is part of the value. Cycles are handled by a WeakMap from left objects to a WeakSet of right objects already compared with them.
Where you’ll use advanced recursion
These techniques show up whenever front-end code walks unknown nested data:
- Walking a file tree from a drag-and-drop upload before showing totals.
- Traversing a DOM-like tree in tests or a content editor without trusting its depth.
- Rendering comment threads where replies can contain more replies.
- Normalizing deeply nested JSON returned by an API.
- Cloning message payloads before sending them to workers or iframes.
- Comparing form snapshots in state-management tools without false positives from key order.
| Question | Prefer | Reason |
|---|---|---|
| Tail call | Source-code position: a call is the last action in a branch. | Helps you reason, but does not save stack in most engines. |
| Trampoline | Return a thunk for the next step; a loop runs the thunks. | Good for deep functional pipelines where you want to keep recursive shape. |
| Explicit stack | Store pending nodes in an array and loop. | Best production default for huge trees, DOM walks, and file-like data. |
| CPS | Pass a continuation callback that represents what should happen next. | Great mental bridge to callbacks and async flow; not stack-safe by itself. |
If the depth comes from users, files, CMS content, DOM shape, or network data, assume it can be deeper than your test fixture. Use a trampoline or explicit stack, and add a test for a large depth.
Common misconceptions
“Tail-recursive JavaScript is automatically safe.”
Not in Chrome, Node, or Firefox. Tail position is useful, but most engines still grow the stack for ordinary recursive calls.
“A trampoline is just a faster recursive function.”
A trampoline trades call frames for loop turns and function allocations. It is stack-safe, not automatically faster.
“CPS means asynchronous.”
CPS uses callbacks as continuations. The callback can run synchronously, like the example, or later, like browser events and promises.
“structuredClone replaces every custom deep clone.”
It is excellent, but it rejects functions and DOM nodes and does not preserve custom class behavior. Custom clone code is a policy decision.
“Deep equal has one correct answer.”
Teams choose policies for -0, prototypes, Maps, Sets, sparse arrays, descriptors, and cycles. Tests should state those choices.
| Misread | Better reading | Safer action |
|---|---|---|
| Tail call means optimized | Tail call is source position; optimization is engine support. | Assume no PTC outside Safari. |
| Returning a thunk is the result | A thunk is one suspended step. | Run it with a trampoline loop. |
| WeakMap is only for performance | It preserves object identity and breaks cycles. | Store before cloning or comparing children. |
| Object and array order are the same | Object key order is usually not semantic; array index order is. | Document your equality policy. |
Practice advanced recursion
5 EXERCISESRead the tail-recursive factorial and type the printed number.
function factorialTail(n, acc = 1) {
if (n <= 1) return acc;
return factorialTail(n - 1, n * acc);
}
console.log(factorialTail(5));The accumulator holds all pending multiplication by the time n reaches 1, so the program prints 120.
Predict the boolean printed by the proof snippet.
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);The trampoline starts with a wrapper thunk and then runs the 100,000 sum thunks, so the comparison prints true without stack overflow.
What should the fixed cycle check print?
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;
}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 && copy.self === copy);The fixed helper checks seen first and stores the clone before copying children. A self-reference can then point at the clone instead of recursing forever.
Predict the two booleans printed by the policy check.
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));This lesson's policy treats NaN as equal to itself and keeps -0 different from 0, so the joined output is true,false.
A social site imports comment threads from users. Some threads are tens of thousands of replies deep. Which technique should the renderer use first: tail recursion, trampoline, CPS, or an explicit stack?
const stack = [...comments];
while (stack.length > 0) {
const comment = stack.pop();
stack.push(...comment.replies);
}A very deep comment tree is best walked with an explicit stack. It keeps one JavaScript stack frame and makes pending work visible.
Check your understanding
8 QUESTIONSQuestion 1 of 8Which call is in tail position?
Choose an answer to see the explanation.
Question 2 of 8What does the tail and non-tail pair print?
Read the code, then predictfunction 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.
Question 3 of 8What is the honest proper-tail-call status in 2026?
Choose an answer to see the explanation.
Question 4 of 8What does the small trampoline print?
Read the code, then predictfunction 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.
Question 5 of 8What does the CPS example print?
Read the code, then predictfunction 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.
Question 6 of 8What does the cycle-aware clone check print?
Read the code, then predictfunction 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.
Question 7 of 8What does the deep-equal policy snippet print?
Read the code, then predictfunction 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.
Question 8 of 8What happens in Chrome or Node when this strict tail-recursive call is 100,000 calls deep?
Read the code, then predict"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
- Tail position means no work remains after the call, but most JavaScript engines still do not reuse the frame.
- Trampolines make recursive steps stack-safe by returning thunks and running them in a loop.
- Explicit stacks are often the clearest production answer for unknown-depth trees.
- CPS turns “what happens next” into a callback, which connects recursion to callback and async patterns.
- Deep clone and deep equal need a seen-pair structure, usually a
WeakMap, to handle cycles honestly.
Remember the one-liner.
Advanced recursion is less about calling yourself and more about deciding where the next step lives: in the call stack, in a thunk, in an explicit array, or in a seen map.
Up next: Functors and monads, where functional patterns move from recursive control flow to values that map, chain, and carry context.