Virtual DOM diffing
Compare the old and new trees of elements node by node and list the smallest set of changes to apply to the real page.
O(n + p)Space O(depth)3 practice problemsStep-through pictureThe idea
Frameworks like React let you describe what the page should look like as a tree of plain objects, often called a virtual DOM. Each object has a type like "div", some props like { class: "card" }, and children. When data changes, you describe the new tree. The framework must then change the real page to match, touching as little as possible, because real DOM changes are slow.
Finding the truly smallest set of changes between two trees is expensive: the best known general methods take about O(n^3) time, too slow for thousands of elements. React instead uses two simple rules. If two elements have a different type, replace the whole subtree without looking inside. If they have the same type, keep the element, update the props that changed, and compare the children. Children are matched by key when you give one, otherwise by position.
With those rules, each node is compared once, so diffing is O(n). The rules can miss a cheaper update in rare cases, but in real apps they work very well.
An everyday example
Updating a printed seating chart
You have last week's seating chart on the wall and this week's version on paper. You compare them table by table. If a table changed from a round table to a long one, you throw away the old table and everything on it and set up the new one. If it is the same kind of table, you only swap the name cards that changed, then check each seat. Seats that only exist in the new chart get added; seats that disappeared get removed.
| In the example | In the code |
|---|---|
| A table on the old and new chart | oldNode and newNode |
| A different kind of table | oldNode.type !== newNode.type, giving a replace patch |
| Swapping the name cards that changed | prop patches for keys whose values differ |
| A seat only in the new chart | !oldNode, giving an insert patch |
| A seat that disappeared | !newNode, giving a remove patch |
From the slow way to virtual dom diffing
- Start with the obvious way to update the page: throw away the old elements and build all the new ones, as setting
innerHTMLdoes. It is correct, but it is slow for big pages, and it destroys things the user can see. Focus jumps out of the input, the cursor position is lost, and scroll positions and playing videos reset. - So we want to keep every real element we can and change only what differs. The exact answer, the smallest list of edits that turns one tree into another, is called tree edit distance. General methods for it take about
O(n^3)time. For 1,000 elements that is around a billion steps, on every update. - Look at how UIs really change. An element almost never jumps to a different parent. And when an element changes type, like a
ulbecoming anol, or a form becoming a loading spinner, its inside is almost always different too. So compare only nodes that sit at the same place, and give up early when the type differs. - That leaves two rules. Different
type: replace the whole subtree and don't look inside. Sametype: keep the element, patch only the props that changed, and then compare the children pair by pair. Each node is looked at once, so the cost is linear. That recursion is virtual DOM diffing.
How it works
- Call
diff(oldNode, newNode, "0", patches)on the two roots.pathnames a node by the child positions from the root, like0.1. - If there is no old node, the new one was added: push an
insertpatch. - If there is no new node, the old one was deleted: push a
removepatch. - If the
typediffers, push areplacepatch and stop. Nothing inside is compared. - Otherwise the element is kept. For every prop name in either node, push a
proppatch if the value changed (nullmeans removed). - Then diff the children in pairs by position, up to the longer list's length, with paths
path.0,path.1, and so on.
Watch it run
This picture runs the code below on a real input. Press Step to move one step at a time, or Play to watch. The highlighted line is the one that just ran. Change the input to see other cases.
See every step at onceThe same run, laid out side by side
The code
Point at a note to light up the lines it explains.
function diff(oldNode, newNode, path = "0", patches = []) {if (!oldNode) {patches.push(["insert", path, newNode.type]);} else if (!newNode) {patches.push(["remove", path]);} else if (oldNode.type !== newNode.type) {patches.push(["replace", path, newNode.type]);} else {const keys = new Set([...Object.keys(oldNode.props), ...Object.keys(newNode.props)]);for (const key of keys) {if (oldNode.props[key] !== newNode.props[key]) {patches.push(["prop", path, key, newNode.props[key] ?? null]);}}const count = Math.max(oldNode.children.length, newNode.children.length);for (let i = 0; i < count; i++) {diff(oldNode.children[i], newNode.children[i], `${path}.${i}`, patches);}}return patches;}
How fast it is
| Time | O(n + p) |
|---|---|
| Extra memory | O(depth) |
Every node is compared once and every prop is checked once. The recursion keeps one call per level of the tree.
- Best case
O(1)The two roots have different types. One
replacepatch is pushed and nothing inside is compared. Building the new subtree later still costs time. - Average case
O(n + p)A typical update: most nodes keep their type, so every node and every prop is compared once.
- Worst case
O(n + p)Every node keeps its type, so all
npositions and allpprops are compared. It never gets worse than linear.
Common mistakes
- Comparing children inside nodes of different types. The rule is to replace; looking inside wastes time and keeps state that should be reset.
- Only looping over the new node's props. Props that were removed are then never cleared. Loop over the keys of both.
- Using array indexes as
keyin lists that get reordered or filtered. The keys then follow positions, not items, and state ends up on the wrong row. - Comparing props that are objects or functions with
!==. A new inline object is a different object each render, so it always looks changed; that is why React usesmemoanduseCallback.
When to use it
Look for these clues in a problem:
- You are given two versions of a tree, such as UI nodes, and must list the changes between them.
- The question describes rules like "same type means update, different type means replace".
- Children are matched by position or by a
keyfield. - The answer is a list of patch operations, often with paths like
0.1.2.
Check yourself
Answer each one in your head first, then open it.
The old root is a div with 500 nodes inside and the new root is a main. How many patches does diff push?
One: ["replace", "0", "main"]. Different types stop the comparison right away, so nothing inside is looked at.
Why does the code loop over the prop names of both nodes, not just the new one?
A prop that was removed only appears in the old props. Looping over the new props alone would never find it, so the old value would stay on the page.
Why does changing a component's type reset its state?
Different types mean replace: the old subtree, including any state in it, is destroyed and a new one is built. Nothing inside is matched up.
What does diff return when the old and new trees are equal?
An empty list. Every node has the same type and no prop value differs, so no patch is pushed.
Practice problems
Stuck on one? Tuto is in the playground too, and gives you hints, never the answer.
0 of 3 solved. Solve one to earn the bronze medal, half for silver, and all of them for gold.
Start practicingGo further
You don't need these to start practicing. Open one when you want more.
Why it works
The reasoning that makes it always right
The promise is about correctness, not about being the smallest: after diff(oldNode, newNode, path) returns, applying the patches it pushed turns the old subtree at path into one that matches newNode. For the first three cases this is clear: an insert adds the new subtree, a remove deletes the old one, and a replace swaps one for the other.
For the same-type case, it holds by working up from the leaves. Every prop name in either node is checked, so after the prop patches the props match, including removed ones, which are sent as null. Each child position up to the longer list is handled by a smaller call to diff, which keeps the same promise for that child. So the element, its props, and every child end up matching.
The recursion ends because each call only goes into children, so it stops at the leaves, and each position in the two trees is visited at most once. The price of the shortcut is that the patch list isn't always the shortest. A list item that moved, or a type change around similar content like the li items in the trace, produces more work than needed. Keys fix the first case.
Going deeper
3 more ideas
Why different types replace the whole subtree
A ul and an ol, or a Form and a Spinner component, rarely share inner structure. Comparing inside them would cost time and almost never find anything to keep. React's rule is to destroy the old subtree, including any state in it, and build the new one. That is why changing a component's type resets its state.
Why keys matter
Matching children by position goes wrong when items are inserted at the front of a list: every item shifts, so every pair looks different. With a key, the diff matches old and new children by key, so it sees that the old items are all still there and only one was added. React warns when list items have no key for this reason. Some libraries, like Vue 3 and Inferno, also use a longest increasing subsequence of the kept items to move as few DOM nodes as possible.
How fast it is
Each node of the old and new trees is visited at most once, and each prop is compared once. That is O(n + p) for n nodes and p props. The recursion goes as deep as the tree, so extra memory is O(depth) plus the patch list.
Work it by hand
Every step in a table, with why
Diff a shopping cart before and after an update. Every kind of patch appears once, and the replaced list's items are never looked at.
old = section{id: "cart"}[h3[span{class: "count"}], ul{class: "items"}[li, li], p{hidden: "yes"}], new = section{id: "cart", class: "open"}[h3, ol{class: "items"}[li, li], p, button{type: "submit"}]
| Step | path | oldNode | newNode | Patch pushed | Why |
|---|---|---|---|---|---|
| 1 | 0 | section | section | ["prop", "0", "class", "open"] | Same type. keys is id and class. id is equal; class is new. Then it compares Math.max(3, 4) = 4 child positions. |
| 2 | 0.0 | h3 | h3 | none | Same type and no props. The old h3 has 1 child and the new one has 0, so it checks 1 position. |
| 3 | 0.0.0 | span | undefined | ["remove", "0.0.0"] | There is no new node at this position, so the old span must go. |
| 4 | 0.1 | ul | ol | ["replace", "0.1", "ol"] | The types differ, so the whole subtree is replaced. The two li children inside are never compared, even though they look the same. |
| 5 | 0.2 | p | p | ["prop", "0.2", "hidden", null] | Same type. hidden is missing from the new props, so it is sent as null, meaning remove it. |
| 6 | 0.3 | undefined | button | ["insert", "0.3", "button"] | There was no fourth child before, so the button is inserted. This was the last position, so the diff ends. |
5 patches from 6 calls of diff. Each tree has 7 nodes, but the 2 li nodes on each side were skipped because their parent was replaced.
Compared with other ways
When to pick something else
| Approach | Time | Extra memory | Choose it when |
|---|---|---|---|
| Virtual DOM diffing | O(n + p) | O(depth) | You re-describe the whole UI on each change and want to keep the real elements, focus, and state, at linear cost. |
Rebuild everything with innerHTML | O(n) | O(n) | The first render, or static content with no state to keep. It is simple but resets focus, input values, and scroll. |
| Exact tree edit distance | O(n^3) | O(n^2) | Offline tools on small trees, such as comparing two XML files, where the truly smallest edit list matters more than speed. |
| Fine-grained updates (signals) | O(k) | O(n) | You can track which k values changed and which nodes use them, so updates touch only those nodes, as in Solid or Svelte. |
Extra memory: O(depth) for the recursion, one call per level of the tree. The patches list can grow to O(n + p) in the worst case.
How the steps grow
- Virtual DOM diffing
O(n + p) - Rebuild everything with
innerHTML, Fine-grained updates (signals)O(n) - Exact tree edit distance
O(n^3)
| Approach | n = 10 | n = 1,000 | n = 1,000,000 |
|---|---|---|---|
| Virtual DOM diffing | 20 | 2,000 | 2 million |
Rebuild everything with innerHTML | 10 | 1,000 | 1 million |
| Exact tree edit distance | 1,000 | 1 billion | more than a quadrillion |
| Fine-grained updates (signals) | 10 | 1,000 | 1 million |
Inputs that need care
6 tricky inputs
- Both trees are empty:
diff(undefined, undefined) - The first branch runs and reads
newNode.type, which throws aTypeError. Addif (!oldNode && !newNode) return patches;at the top. Inside the recursion this can't happen, because one of the two lists always has a child at indexi. - The same tree twice
- Every type and every prop is equal, so the result is
[]. This is the common case that must be fast. - An item is added at the front of an unkeyed list:
[A, B, C]becomes[Z, A, B, C] - Positions are compared, so it pushes 3
proppatches (AtoZ,BtoA,CtoB) and oneinsertat the end. With 100 items it is 101 patches for a single new item. Keys fix this. - Two children are removed from the end:
[A, B, C, D]becomes[A, B] - It pushes
remove 0.2thenremove 0.3. If you apply them in that order, removing index 2 shifts the old index 3 down, so apply removes from the highest index down, or look up all nodes before changing anything. - A prop whose value is a new object each time, like
style: { color: "red" } !==compares references, so it always looks changed and a patch is pushed every time. React's DOM renderer comparesstyleobjects key by key, anduseMemokeeps the same object between renders.- Text children, like the string
"Hello" - This code expects every child to have
type,props, andchildren. Real libraries treat text as its own kind of node and compare the strings themselves.
Variations and follow-ups
Twists interviewers add
Keyed children
Put the old children in a Map by key. For each new child, look up the old child with the same key and diff the two, wherever they sit. Old children whose key no longer appears are removed. React then decides which DOM nodes to move by tracking the last kept position, and Vue 3 finds a longest increasing subsequence of the kept items, which keeps the largest set of nodes in place and moves the rest.
Two pointers at both ends
Snabbdom, and Vue 2 which was based on it, compare the children with four pointers: the start and end of the old list and of the new list. Common edits like adding at the front, removing from the end, or reversing are caught in one pass before any map is needed.
Skipping unchanged components
React.memo and shouldComponentUpdate let a component say "my props didn't change", so the whole subtree below it is skipped. Then the diff costs time in proportion to what changed, not to the size of the page.
No virtual DOM at all
Svelte and Solid compile templates into code that knows which DOM node shows which value. When a value changes, they update that node directly, so there is no tree to diff.
In an interview
Spot it, explain it, answer follow-ups
- Spot it from "two versions of a tree", "list the patches", "same type means update", or a mention of keys. The output is usually a list of operations with paths.
- Explain the two rules and why they are guesses: exact tree diffing is about
O(n^3), and these rules giveO(n)because UI updates rarely move nodes between parents. - Test by hand: a root whose type changes, a child added at the end, a child removed, and a prop that disappears. The last one catches code that only loops over the new props.
- Expect the follow-up "how would keys change your children loop?". Answer: map old children by key, diff each new child against the old child with the same key, remove the old ones that weren't used, and then move nodes into the new order.
Where frontend code uses it
The same idea in real UI code
- React's reconciliation follows these two rules: elements of different types produce different trees, and
keytells React which children are the same between renders. - Vue, Preact, and Inferno each diff virtual nodes with similar rules, and Vue 3 moves keyed children using a longest increasing subsequence.
- Libraries that patch the real DOM directly, like
morphdom, compare an old and new DOM tree with the same same-type-means-update idea.