Optimizing Lodash intersectionWith for DOM Nodes
Lodash's _.intersectionWith offers a flexible way to
find common elements between arrays using a custom comparator, but it
can cause severe performance bottlenecks when handling arrays of complex
DOM nodes. Because the method relies on an \(O(n \times m)\) comparison matrix,
evaluating deeply nested DOM trees or extensive node properties
repeatedly blocks the main JavaScript thread. This article covers the
exact causes of DOM comparison latency in Lodash and provides practical
strategies to optimize execution time, ranging from native DOM equality
methods to hash-based lookup conversions.
The Cause of Performance Degradation
By default, using a deep comparison function like Lodash's
_.isEqual inside _.intersectionWith is
disastrous for DOM nodes. A DOM node is not a simple plain object; it
has circular references (such as parentNode,
ownerDocument, and child traversal pointers) and hundreds
of prototype accessors. Deep traversal attempts can trigger massive
overhead, unexpected prototype crawls, and heavy garbage collection
overhead.
Furthermore, _.intersectionWith runs the comparator
function for every combination of elements across arrays until a match
is found. If both arrays contain 1,000 nodes, the comparator can execute
up to 1,000,000 times.
1. Replace Generic Deep Equality with Native APIs
If you are comparing nodes for reference identity or structural equivalence, always replace generic recursive comparators with native browser methods executed in compiled C++:
- Reference Identity (
Node.isSameNodeor Strict Equality): If you only need to determine if two arrays contain the exact same DOM node instances, use strict equality (===) ornodeA.isSameNode(nodeB):const result = _.intersectionWith(arrayA, arrayB, (a, b) => a.isSameNode(b)); - Structural Equivalence
(
Node.isEqualNode): If you need to test whether two distinct nodes have the same attributes, tag, and child subtree, use the nativeisEqualNode:Nativeconst result = _.intersectionWith(arrayA, arrayB, (a, b) => a.isEqualNode(b));isEqualNodeis heavily optimized compared to JavaScript-based recursive traversals and avoids traversing internal browser metadata.
2. Implement Fast-Failing Heuristics
When a custom comparison of specific DOM attributes or subtrees is required, avoid running heavy checks immediately. Implement cheap "fast-fail" checks at the start of your comparator to exit as early as possible:
function fastNodeComparator(nodeA, nodeB) {
// 1. Check reference equality first
if (nodeA === nodeB) return true;
// 2. Check cheap surface-level properties
if (nodeA.nodeType !== nodeB.nodeType) return false;
if (nodeA.nodeName !== nodeB.nodeName) return false;
if (nodeA.childElementCount !== nodeB.childElementCount) return false;
// 3. Fall back to heavier comparison only if surface properties match
return nodeA.isEqualNode(nodeB);
}
const result = _.intersectionWith(arrayA, arrayB, fastNodeComparator);3. Transition from \(O(n \times m)\) to \(O(n + m)\) via Serialization and Sets
The most effective optimization is often abandoning
_.intersectionWith altogether. If the criteria for equality
can be reduced to a string (such as an ID, an XPath, an outerHTML
string, or a generated hash), map the nodes to identifiers and use
Set lookups or _.intersectionBy.
Using native Set lookups drops the time complexity from
quadratic \(O(n \times m)\) to linear
\(O(n + m)\):
// Precompute signatures into a Set for O(1) lookups
const generateSignature = (node) => {
// Use a lightweight unique identifier appropriate for your application
return `${node.tagName}#${node.id}.${node.className}`;
};
const signatureSet = new Set(arrayB.map(generateSignature));
const result = arrayA.filter(node => signatureSet.has(generateSignature(node)));4. Cache Expensive Properties with WeakMap
If your comparison logic must compute expensive metrics (like
computed styles via window.getComputedStyle or bounding
client rects), never compute them repeatedly inside the quadratic
comparator loop. Pre-calculate or memoize these properties using a
WeakMap so they are calculated only once per node:
const layoutCache = new WeakMap();
function getCachedLayout(node) {
let layout = layoutCache.get(node);
if (!layout) {
const rect = node.getBoundingClientRect();
layout = { width: rect.width, height: rect.height };
layoutCache.set(node, layout);
}
return layout;
}
const result = _.intersectionWith(arrayA, arrayB, (a, b) => {
const layoutA = getCachedLayout(a);
const layoutB = getCachedLayout(b);
return layoutA.width === layoutB.width && layoutA.height === layoutB.height;
});Using WeakMap ensures that memory leaks are prevented,
as entries are garbage collected automatically when the underlying DOM
elements are removed from memory.