Lodash flattenDeep and Circular Array Stack Overflows
This article examines how the Lodash JavaScript library handles
circular, infinitely recursive arrays during _.flattenDeep
operations. It explores the internal architecture of Lodash's flattening
mechanism, addresses the common misconception regarding cycle prevention
in flattenDeep, contrasts its implementation with
circular-safe utilities like cloneDeep, and provides
practical solutions for flattening deeply nested data structures
containing circular references without crashing the JavaScript
runtime.
The Reality
of _.flattenDeep and Circular References
Contrary to common assumption, Lodash does not
prevent stack overflows when executing _.flattenDeep on
circularly recursive arrays. Passing a self-referencing array into
_.flattenDeep will cause the JavaScript engine to throw a
RangeError: Maximum call stack size exceeded.
To understand why this occurs, it is necessary to examine Lodash's internal source code.
The Internal Mechanics of
baseFlatten
In Lodash, _.flattenDeep is a wrapper around an internal
utility function named baseFlatten. The implementation
invokes baseFlatten by passing the source array and setting
the recursion depth limit to INFINITY:
function flattenDeep(array) {
const length = array == null ? 0 : array.length;
return length ? baseFlatten(array, INFINITY) : [];
}Inside baseFlatten, Lodash iterates through the array
elements. When it encounters an element that satisfies the
isFlattenable predicate (such as a standard array or an
arguments object), it recursively invokes
baseFlatten with a decremented depth:
function baseFlatten(array, depth, predicate, isStrict, result) {
let index = -1;
const length = array.length;
predicate || (predicate = isFlattenable);
result || (result = []);
while (++index < length) {
const value = array[index];
if (depth > 0 && predicate(value)) {
if (depth > 1) {
// Recursively flatten arrays
baseFlatten(value, depth - 1, predicate, isStrict, result);
} else {
arrayPush(result, value);
}
} else if (!isStrict) {
result[result.length] = value;
}
}
return result;
}Because depth begins at Infinity,
subtracting 1 still evaluates to Infinity
(Infinity - 1 === Infinity). Consequently, the condition
depth > 1 always evaluates to true. Without
an exit condition triggered by depth depletion or reference tracking,
the recursive calls consume the call stack until the runtime limit is
breached.
Why Lodash Omits Cycle Detection in Flattening
Lodash deliberately avoids tracking visited references in
baseFlatten for performance reasons:
- Performance Overhead: Tracking visited objects
requires creating and querying a memoization structure—such as a
SetorWeakSet—on every element traversal. Because array flattening is a performance-critical primitive, adding lookup overhead degrades speed for standard non-circular arrays. - Structural Purpose: Unlike
_.cloneDeepor_.isEqual, which utilize an internalStackclass to detect circular graphs and replicate object identity, flattening is intended for tree structures, not arbitrary cyclic graphs.
How to Safely Flatten Circular Arrays
To flatten arrays that may contain infinite cycles without causing a
stack overflow, you must track previously traversed references using a
WeakSet or implement an iterative approach rather than
standard recursion.
function safeFlattenDeep(array, seen = new WeakSet()) {
const result = [];
if (!Array.isArray(array)) {
return result;
}
seen.add(array);
for (const item of array) {
if (Array.isArray(item)) {
if (!seen.has(item)) {
result.push(...safeFlattenDeep(item, seen));
}
} else {
result.push(item);
}
}
return result;
}
// Example usage:
const circularArray = [1, [2]];
circularArray[1].push(circularArray);
console.log(safeFlattenDeep(circularArray)); // Output: [1, 2]This approach intercepts repeated references before invoking recursion, ensuring the operation completes safely in linear time without exhausting the execution stack.