TL;DR / React's virtual DOM diff uses O(n) heuristics instead of the theoretically optimal O(n^2) tree edit distance algorithm, trading minimal edits for practical speed by assuming cross-type changes always warrant full remounts.
How It Works
Naive tree diff: O(n^3) React heuristic diff: O(n)
┌───────┐ ┌───────┐
│ A │ │ A │
└───────┘ └───────┘
│ │
┌─────└──────┐ ┌─────└──────┐
↓ ↓ ↓ ↓
┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐
│ B │ │ C │ │ B │ │ C │
└───────┘ └───────┘ └───────┘ └───────┘
all pairs compared level-by-level only
= O(n^2) to O(n^3) one pass = O(n)
The virtual DOM is a lightweight in-memory representation of the real DOM. When component state changes, React builds a new virtual DOM tree and diffs it against the previous one to compute the minimum set of DOM mutations. The performance of this diffing algorithm directly determines how fast React can respond to state changes.
The Theoretical Problem
Computing the minimum edit distance between two arbitrary trees — the minimum number of insertions, deletions, and moves to transform one tree into another — is a well-studied problem in computer science. The best general algorithm (Zhang-Shasha) runs in O(n^2) time and O(n^2) space for ordered labeled trees, though earlier algorithms were O(n^3). For unordered trees, the problem is NP-hard.
React cannot afford even O(n^2). A moderately complex page has thousands of virtual DOM nodes. At O(n^2), a 5,000-node tree requires 25 million comparisons per render. At 60 renders per second (during animations or interactions), that is 1.5 billion comparisons per second — far beyond what JavaScript can handle while maintaining frame rate.
React's O(n) Heuristic
React achieves O(n) by abandoning the goal of finding the minimum edit distance. Instead, it applies two heuristics:
Heuristic 1: Different types produce different trees. React never tries to "morph" a <div> into a <span> or a <Header> component into a <Footer> component. If the type changes at a given tree position, React tears down the entire old subtree and builds a new one. This eliminates cross-type comparison entirely — a single check per node.
Heuristic 2: Keys identify stable elements. For sibling lists, keys allow React to match old elements to new elements by identity rather than position. This handles insertions, deletions, and reorders without pairwise comparison of all siblings.
The resulting algorithm is a single depth-first pass: visit each node once, compare it to the corresponding node in the old tree (matched by position and key), and decide whether to update, mount, or unmount. The cost is proportional to the number of nodes — O(n).
The Cost of Heuristic 1
The "different type = different tree" heuristic is aggressive. Consider changing <article> to <section>. Semantically, these elements are nearly identical. A minimal diff would keep the DOM node and change the tag. React's heuristic unmounts the entire <article> subtree — including all child components, their state, their DOM nodes, and their effects — and remounts everything under a new <section>. For a subtree with 200 nodes, this means 200 destructions and 200 creations instead of one tag change.
This tradeoff is intentional. Checking whether two different types can share a DOM structure would require deep structural comparison, destroying the O(n) guarantee. In practice, element types rarely change at the same position. When they do, the subtree usually needs different styling and behavior anyway, making a remount semantically correct.
List Reconciliation Details
List diffing is where keys matter most. Without keys, React compares list items by index. Inserting an item at position 0 shifts every subsequent item's index, causing React to update every item's DOM. With keys, React identifies which items moved, were added, or were removed, producing minimal DOM operations.
React's list reconciliation uses a linear scan with a fallback to a map-based lookup. It first tries to match new children left-to-right with old children. When it encounters a mismatch, it builds a map of remaining old children keyed by their key (or index). Each subsequent new child checks the map for a match. This is O(n) in the common case (appending items, removing items) and O(n) with constant factors for reorders.
Comparison With Other Approaches
Svelte avoids virtual DOM diffing entirely — its compiler generates direct DOM update instructions at build time. Solid.js uses fine-grained reactivity to track which specific DOM nodes depend on which signals, updating only those nodes without a tree diff. Vue uses a virtual DOM but with compiler-assisted optimizations — template analysis identifies static subtrees and skips them during diffing.
These approaches can outperform React's virtual DOM diff for specific patterns (especially lists and highly static pages). React's approach trades peak efficiency for generality — the heuristic works reasonably well for any component tree structure without requiring compiler analysis or reactive primitives.
When Diffing Becomes the Bottleneck
For most applications, reconciliation is fast enough that it is not the bottleneck. Where it does become a problem is rendering very large trees: tables with thousands of rows, deeply nested component hierarchies, or pages with tens of thousands of elements. In these cases, the solution is not to optimize the diff but to reduce the tree size — virtualization (rendering only visible rows), pagination, or React.memo to skip unchanged subtrees entirely.
Gotchas
React.memoskips reconciliation for a subtree entirely when props have not changed. This is the most effective optimization for large trees — it turns O(n) into O(1) for memoized subtrees.- Changing a wrapper element type (div to span, etc.) remounts all children. This destroys state, refs, and effects for the entire subtree. Avoid dynamic wrapper types at the same tree position.
- Key stability is critical. Using
Math.random()or array index as keys defeats the purpose of keyed reconciliation. Random keys cause full remounts every render. Index keys cause incorrect reuse during reorders. - Virtual DOM overhead is fixed per node, not per change. Even if only one prop changed, React still visits and compares every node in the tree (unless memoized). The diff is O(n) in tree size, not O(c) in changes.
- Object identity matters for props diffing. React uses
Object.isto compare prop values. Passing a new object literalstyle={{ color: 'red' }}on every render triggers an update even though the value is semantically identical. Hoist objects or useuseMemo.