Recursion
Mental model: Trust the function with a smaller version of the problem, and stop at the version too small to shrink.
Level: intermediate · about 12 minutes
function countdown(n) {
if (n <= 0) return ['liftoff']; // base case: stop here
return [n, ...countdown(n - 1)]; // recursive case: smaller problem
}
console.log(countdown(3)); // → [3, 2, 1, 'liftoff']Two lines of logic: when to stop, and how to shrink.
A recursive function calls itself. Every one needs two parts: a base case that returns without recursing, and a recursive case that calls itself with input that is measurably closer to the base case. Miss either and you get a stack overflow.
- Write the base case first Ask: what is the smallest input, and what is the answer for it? For a sum, an empty list totals zero.
- Assume the function already works for smaller input The sum of a list is the first item plus the sum of the rest. You do not need to know how the rest is summed.
- Check that the input really shrinks
restis always one shorter, so the base case is reached in exactlylengthsteps. If you cannot name what shrinks, you have an infinite recursion.
The call stack is a real, finite thing
┌──────────────────────────────┐ │ total([]) → returns 0 │ ← top, resolves first ├──────────────────────────────┤ │ total([2]) → 2 + 0 = 2 │ ├──────────────────────────────┤ │ total([1, 2]) → 1 + 2 = 3 │ ← bottom, resolves last └──────────────────────────────┘ Nothing is added up until the base case returns.
let deepest = 0;
function probe(n) {
deepest = n;
probe(n + 1); // no base case on purpose
}
try {
probe(1);
} catch (err) {
console.log(err.constructor.name); // → 'RangeError'
console.log('frames before overflow:', deepest);
}Find your engine limit. The exact number varies by engine and frame size.
Where recursion is the natural fit
Recursion shines on data that contains more of itself: file trees, comment threads, JSON, the DOM, nested menus. The code then has the same shape as the data, which is the real payoff.
const tree = {
name: 'src',
children: [
{ name: 'index.js', children: [] },
{ name: 'lib', children: [{ name: 'util.js', children: [] }] },
],
};
function paths(node, prefix = '') {
const here = `${prefix}/${node.name}`;
return [here, ...node.children.flatMap((child) => paths(child, here))];
}
console.log(paths(tree));
// → ['/src', '/src/index.js', '/src/lib', '/src/lib/util.js']
Recursion versus iteration
Recursive: matches the data
function depth(node) {
if (!node.children.length) return 1;
return 1 + Math.max(
...node.children.map(depth)
);
}
Iterative: matches the machine
function depth(root) {
let max = 0;
const stack = [[root, 1]];
while (stack.length) {
const [node, d] = stack.pop();
max = Math.max(max, d);
for (const c of node.children) stack.push([c, d + 1]);
}
return max;
}The recursive version is shorter and reads like the definition. The iterative version cannot overflow the stack, because the pending work lives in an array on the heap instead. Reach for it when the depth is unbounded or comes from user data.
| Prefer | When |
|---|---|
| recursion | the data is nested: trees, JSON, the DOM, menus, threads |
| recursion | the definition is naturally recursive, as with a traversal or a divide and conquer |
| iteration | you are walking a flat list or counting |
| iteration | the depth could be thousands, or is attacker controlled |
| iteration with an explicit stack | you want the recursive shape without the stack limit |
Accumulator style
// Builds up on the way back: nothing is known until the base case returns.
const sumUp = (nums) => (nums.length === 0 ? 0 : nums[0] + sumUp(nums.slice(1)));
// Carries the total down: each call already has the whole answer so far.
const sumDown = (nums, acc = 0) =>
nums.length === 0 ? acc : sumDown(nums.slice(1), acc + nums[0]);
console.log(sumUp([1, 2, 3]), sumDown([1, 2, 3])); // → 6 6Carry the answer down instead of building it on the way back up.
The accumulator version is a tail call: the recursive call is the last thing that happens, so nothing is left to do after it returns. In a language with tail call optimisation that costs one stack frame. In JavaScript, treat it as a style that makes the state obvious, not as a way to avoid the stack limit.
function count(n) {
if (n === 0) return 0;
return 1 + count(n - 2);
}
console.log(count(5));From an odd number, n goes 5, 3, 1, then -1, -3 and onwards. It steps straight over the base case of exactly 0, so the recursion never ends and the stack overflows. Base cases should be written as boundaries (n <= 0), not as exact hits.
Try it yourself
Traverse a nested structure
const menu = {
label: 'root',
items: [
{ label: 'file', items: [{ label: 'open', items: [] }, { label: 'save', items: [] }] },
{ label: 'edit', items: [{ label: 'copy', items: [] }] },
],
};
function labels(node) {
return [node.label, ...node.items.flatMap(labels)];
}
function countNodes(node) {
return 1 + node.items.reduce((total, child) => total + countNodes(child), 0);
}
console.log(labels(menu));
console.log('nodes:', countNodes(menu));
Add a maxDepth function. Then break the base case on purpose and read the error you get.
Exercises
Flatten any depth
Write flattenDeep(items) which returns a single-level array containing every non-array value, in order, no matter how deeply nested. Do not use Array.prototype.flat, and do not modify the input.
Count the leaves
Write countLeaves(value) which counts the non-object values inside an arbitrarily nested plain object. A value that is not an object counts as one leaf, and null counts as a leaf even though typeof null is 'object'. An empty object has no leaves.
Check yourself
- What does this log?
- 24 — This is factorial:
4 * 3 * 2 * f(1), andf(1)returns1, giving24. Each call waits for the one below it, so nothing multiplies until the base case returns. - What causes
RangeError: Maximum call stack size exceeded? - Too many frames on the call stack, usually a missing or unreachable base case — Each pending call occupies a frame, and the stack is finite. A missing base case, or one the input steps over, means the frames never unwind. Genuinely deep data needs an explicit stack in an array instead.
- Which problem is the better fit for recursion than for a plain loop?
- Collecting every label in a nested menu tree — The tree contains more trees, so the code can have the same shape as the data. The other three are flat, and a loop says what they do with fewer moving parts.
Common mistakes
- Writing a base case as an exact match (
n === 0) when the step can skip past it. Use a boundary (n <= 0). - Recursing on input that does not shrink, which overflows the stack instead of failing loudly.
- Assuming tail calls are optimised in JavaScript engines. Write the accumulator style for clarity, not for depth.
Takeaways
- Every recursion needs a base case and a step that provably shrinks the input.
- Pending calls occupy real stack frames, and the stack has a hard limit.
- Nested data is where recursion pays off, because the code mirrors the shape of the data.
- For unbounded depth, move the pending work into an explicit stack in an array.