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++:

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.