JavaScript Reduce Explained: Folds, Recursion and Trampolines
Every list operation is a fold: map, filter and groupBy written with reduce, the O(n²) accumulator-spread trap, JavaScript's missing tail calls and trampolines.
- Course: JavaScript study plan
- Module: Functional patterns
- Kind: Lesson
- Reading time: 12 min
- Runtime: Node 16
How does reduce work in JavaScript?
reduce walks an array from left to right, passing an accumulator and each element to a callback whose return value becomes the next accumulator, and returns the final accumulator. That makes it a fold, the general loop behind map, filter, groupBy, sum and pipe. Always pass a seed as the second argument: without one reduce starts from the first element and throws on an empty array.
Lesson
Every list operation is a fold: walk the elements, carry an accumulator, produce a result. reduce is the fold; map, filter, flatMap, groupBy, partition, zip, unique and sum are all reduce with a particular accumulator, and being able to write each of them in one line is the difference between using the array methods and understanding them. Recursion is the other universal loop, and JavaScript has one hard limit on it — no tail-call elimination in V8 — which this lesson addresses with the two standard escapes: rewrite as a loop, or trampoline. It closes with the honest comparison: when a fold clarifies, when a loop is simply better, and how to avoid the O(n²) trap that lurks in "elegant" immutable reduces.
Everything is a fold
const map = (f, xs) => xs.reduce((acc, x) => (acc.push(f(x)), acc), []);
const filter = (p, xs) => xs.reduce((acc, x) => (p(x) ? acc.push(x) : 0, acc), []);
const flatMap = (f, xs) => xs.reduce((acc, x) => acc.concat(f(x)), []);
const groupBy = (key, xs) => xs.reduce((acc, x) => { (acc[key(x)] ??= []).push(x); return acc; }, {});
const partition = (p, xs) => xs.reduce(([yes, no], x) => (p(x) ? yes.push(x) : no.push(x), [yes, no]), [[], []]);
const zip = (xs, ys) => xs.reduce((acc, x, i) => (i < ys.length ? (acc.push([x, ys[i]]), acc) : acc), []);
const unique = (xs) => xs.reduce((acc, x) => (acc.includes(x) ? acc : (acc.push(x), acc)), []); // O(n²) — use a Set
const countBy = (key, xs) => xs.reduce((m, x) => m.set(key(x), (m.get(key(x)) ?? 0) + 1), new Map());
const indexBy = (key, xs) => new Map(xs.map((x) => [key(x), x]));
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
const maxBy = (f, xs) => xs.reduce((best, x) => (best === undefined || f(x) > f(best) ? x : best), undefined);
const pipeline = (fns, x) => fns.reduce((acc, f) => f(acc), x); // pipe is a fold too
Two things to notice. The accumulator is mutated in most of these (acc.push, m.set) — that is fine and fast because the accumulator was created by this very reduce and nobody else sees it until it is returned; the function stays pure. And each has a seed; without one reduce uses the first element and throws on an empty array.
The O(n²) trap
xs.reduce((acc, x) => [...acc, f(x)], []); // copies the whole accumulator every step: O(n²)
xs.reduce((acc, x) => ({ ...acc, [x.id]: x }), {}); // same
Spreading the accumulator "to stay immutable" allocates n arrays of growing size — 10 000 items become 50 million element copies. Mutate the local accumulator (acc.push) or use Object.fromEntries(xs.map(...))/new Map(...). Immutability matters at the boundary of a function, not inside its own scratch space.
reduceRight, and folds over other shapes
reduceRight folds from the end — used for compose, for building a right-nested structure, or for processing a list in reverse without copying. Folds are not only for arrays: a tree fold visits nodes recursively with an accumulator; a Map/Set can be folded with for…of or [...map].reduce; a generator can be folded lazily by a consuming loop.
Recursion
const depth = (node) => (node.children.length ? 1 + Math.max(...node.children.map(depth)) : 1);
const flatten = (xs) => xs.flatMap((x) => (Array.isArray(x) ? flatten(x) : [x]));
const permutations = (xs) => (xs.length <= 1 ? [xs] : xs.flatMap((x, i) => permutations([...xs.slice(0, i), ...xs.slice(i + 1)]).map((p) => [x, ...p])));
Recursion is the natural fit for recursive data — trees, nested arrays, JSON, expression grammars — and for divide-and-conquer. Every recursive function has a base case (the smallest input, answered directly) and a recursive case (a smaller input plus one step). Write the base case first.
The stack limit and the missing tail calls
Each call uses a stack frame; V8's default stack holds roughly ten thousand frames before RangeError: Maximum call stack size exceeded. Many languages eliminate frames for tail calls — a call in return position needs no frame to return to — and ES2015 specified it, but V8 (and every other major engine except Safari) never shipped it. So const sum = (n, acc = 0) => n === 0 ? acc : sum(n - 1, acc + n) overflows at large n in Node regardless of being tail-recursive.
Two escapes:
- Rewrite as a loop. Any tail-recursive function converts mechanically: the parameters become variables, the recursive call becomes reassignment inside
while. This is the right answer for linear recursion over big inputs. - Trampoline. Return a thunk (a zero-argument function describing the next call) instead of calling; a driver loop keeps calling until a non-function comes back. The recursion becomes iteration without changing the function's shape:
const trampoline = (f) => (...args) => {
let result = f(...args);
while (typeof result === "function") result = result(); // each bounce is a fresh, shallow call
return result;
};
const sumTo = trampoline(function go(n, acc = 0) {
return n === 0 ? acc : () => go(n - 1, acc + n); // return the NEXT step, do not make it
});
sumTo(1_000_000); // 500000500000, no overflow
Trampolines cost an allocation per step and only handle tail calls; for tree recursion (two recursive calls whose results combine) use an explicit stack or queue instead. Depth-first over a tree with an explicit stack array is the standard iterative form.
Fold versus loop, honestly
A reduce is right when the operation is a fold that reads as one idea: sum, max, group, index, pipe. It is wrong — for readers — when the callback grows past four or five lines, mixes several accumulators, or needs early exit (reduce cannot break; for…of can, and so can some/find). Performance: a well-written for loop is faster than reduce by a small constant; nested spreads in a reduce are catastrophically slower. Choose by readability first, then measure.
Common mistakes
reducewithout a seed on possibly-empty input.[...acc, x]/{ ...acc, k }insidereduce— O(n²).- Recursion on large linear inputs without a loop or trampoline; expecting tail-call optimisation in Node.
uniqueviaincludes(O(n²)) instead of aSet.- Forcing early-exit logic into
reducewith a flag instead of using a loop,someorfind. - A recursive function whose base case is unreachable for some inputs (negative numbers, empty arrays).
Interview angle
- "Implement
mapusingreduce."xs.reduce((acc, x) => (acc.push(f(x)), acc), []). - "Why is
reduce((acc, x) => [...acc, x], [])slow?" Copies the accumulator each step — O(n²). - "Does JavaScript have tail-call optimisation?" Specified in ES2015, but V8 does not implement it; use a loop or a trampoline.
- "What is a trampoline?" A driver that repeatedly calls returned thunks, turning tail recursion into iteration with constant stack.
- "When would you not use
reduce?" Long callbacks, several accumulators, or early exit — a loop reads better and canbreak.
Key takeaways
map,filter,flatMap,groupBy,partition,zip,countBy,pipe— all folds overreducewith a seed; mutate the local accumulator.- Never spread the accumulator inside
reduce;Set/Mapfor uniqueness and lookup. - Recursion for recursive data; base case first; V8 has ~10k frames and no tail calls.
- Linear recursion → loop; tail recursion → trampoline (thunks + driver); tree recursion → explicit stack.
- Fold when it reads as one idea; loop when it needs
break, several accumulators or speed.
Common questions
How do you implement map with reduce in JavaScript?
Start from an empty array and push each transformed element: xs.reduce((acc, x) => (acc.push(f(x)), acc), []). Mutating the accumulator is safe because this reduce created it and nothing else sees it until it is returned, so the function as a whole stays pure. filter is the same shape with a condition around the push.
Why is spreading the accumulator in reduce slow?
xs.reduce((acc, x) => [...acc, x], []) copies the whole accumulator on every step, so the work grows as O(n²): 10 000 items mean about 50 million element copies. Object spread in the accumulator has the same problem. Push into the local accumulator, or build with Object.fromEntries or new Map instead.
Does JavaScript have tail call optimization?
Not in practice. ES2015 specified proper tail calls, but V8, and so Chrome and Node.js, never implemented them; among the major engines only Safari's did. A tail-recursive function therefore still overflows a default stack of roughly ten thousand frames with RangeError: Maximum call stack size exceeded. Rewrite it as a loop or use a trampoline.
What is a trampoline in JavaScript?
A trampoline turns tail recursion into a loop. The recursive function returns a thunk, a zero-argument function describing the next call, instead of making the call, and a driver keeps calling the returned functions until a non-function value comes back. The stack stays shallow, so summing to a million recursively no longer overflows.
Exercises
The array toolbox, rebuilt with reduce
Using only reduce inside each (no map/filter calls), implement map(f, xs), filter(p, xs), flatMap(f, xs), groupBy(key, xs), partition(p, xs), zip(xs, ys), unique(xs) (via a Set accumulator) and countBy(key, xs) (a Map). Over the input numbers print, one per line: map=<squares>, filter=<evens>, flatMap=<[x, -x] for positives>, groupBy=<odd/even>, partition=<[≥5, <5]>, zip=<with ["a","b","c"]>, unique=<>, countBy=<entries of x % 3 sorted by key>, sum=<> max=<>.
Example: 3 8 0 12 5 8 -1 →
map=[9,64,0,144,25,64,1]
filter=[8,0,12,8]
flatMap=[3,-3,8,-8,12,-12,5,-5,8,-8]
groupBy={"odd":[3,5,-1],"even":[8,0,12,8]}
partition=[[8,12,5,8],[3,0,-1]]
zip=[[3,"a"],[8,"b"],[0,"c"]]
unique=[3,8,0,12,5,-1]
countBy=[[-1,1],[0,3],[2,3]]
sum=35 max=12A trampoline for deep recursion
Write trampoline(f): returns a function that calls f and, while the result is a function, keeps calling it. Using it, write a tail-recursive sumTo(n) whose recursive step returns a thunk instead of recursing, a plain-loop sumLoop(n), and mutually recursive isEven/isOdd via thunks. Print sumTo=<> sumLoop=<> equal=<>; isEven(n)=<> isEven(n+1)=<>; bounces for <n>: <thunks executed by a counting version> result=<its return for 0>; and plainRecursion(2000)=<the naive recursive sum for 2000> (small depths are fine without a trampoline).
Example: 100000 →
sumTo=5000050000 sumLoop=5000050000 equal=true
isEven(100000)=true isEven(100001)=false
bounces for 100000: 100001 result=done
plainRecursion(2000)=2001000 (small depths are fine without a trampoline)
Plain recursion to 100000 would overflow the stack; the trampolined version uses constant stack.
In this module: Functional patterns
- Pure functions and immutability — why, and how far to take it
- Composition, currying and point-free style — building programs from small functions
- Folds, recursion and trampolines — reduce as the universal loop (this lesson)
- Memoization and laziness — computing once, and only when asked
- Maybe, Result and railway-oriented code — making absence and failure values
- Checkpoint — Functional patterns
← Composition, currying and point-free style — building programs from small functions · Memoization and laziness — computing once, and only when asked →