Tags: web-dev concept

Virtual DOM

Date: 2026-08-19


A tree of plain objects describing what the page should look like, diffed against the previous one to work out the minimum set of real DOM operations. It was never faster than hand-written DOM code — the claim was that it’s fast enough to make re-describing the whole UI on every change affordable.


A virtual DOM is an in-memory representation of the desired UI, cheap enough to rebuild from scratch, which the framework compares against the previous version to derive real DOM mutations.

STATE CHANGE
     ↓
render() runs           →  NEW TREE            OLD TREE
                           ul                  ul
                           ├ li "Shoes"        ├ li "Shoes"
                           ├ li "Socks"  ←new  └ li "Hat"
                           └ li "Hat"
     ↓
diff                    →  insert li "Socks" at index 1     ← the only real work
     ↓
patch the DOM

Two nodes, one insertion. The point is that the author wrote the whole list again and the framework worked out the single operation.

Why not just touch the DOM

Because DOM writes are not the expensive part — coordinating them is. Reading a layout property after a write forces synchronous Reflow and Repaint, and hand-written code interleaves reads and writes constantly. A diff batches all mutations into one pass, so layout is invalidated once.

The real argument was always ergonomic, though: it lets Declarative vs Imperative UI be affordable, because “re-describe everything” stops being absurd.

What the diff can and can’t do

The general tree-diff problem is O(n³). No framework does that. They ship a heuristic in O(n) built on two assumptions:

  • Different element types produce different trees. A <div> replaced by a <section> is torn down entirely, children and their state included — never reconciled
  • Children keep identity via a stable key. Without keys, position is identity
{items.map(item => <Row key={item.id} item={item} />)}   // identity survives reorder
{items.map(item => <Row item={item} />)}                 // identity is the index

The keyless failure is the one to recognise: delete the first row of a list and every row’s state shifts up one — an open dropdown, a focused input, an in-flight animation all attach to the wrong item. Nothing errors. The rendered text is correct, which is what makes it hard to see.

Index as key is the same bug wearing a disguise. key={i} is exactly the positional identity the key was meant to replace.

What it costs

  • Memory — the previous tree is retained for comparison
  • Work proportional to output, not to change. Re-rendering a 500-row table to change one cell costs 500 nodes of description and diff. This is the structural complaint, and the reason for memoisation APIs
  • Runtime weight — the diff algorithm ships to every visitor

Why frameworks are moving away

Signals make the framework’s question different. Diffing asks “what changed anywhere?”; fine-grained reactivity already knows, because the subscription was recorded when the value was read. If you know the exact text node bound to a value, comparing trees is redundant work.

So the newer generation skips the tree comparison entirely: Solid and Svelte never had it, Angular’s signal-based rendering drops it, and Vue ships a compiled no-diff mode alongside its virtual DOM rather than instead of it — opt-in per component while the default stays as it was. React’s answer is a compiler that inserts memoisation automatically, keeping the model and removing the manual tuning.

The honest summary: the virtual DOM solved the right problem — making declarative UI viable — with the tools available in 2013, when tracking every dependency at runtime looked more expensive than it turned out to be. It’s being retired by better answers to the same question, not by a return to imperative code. See Reactivity for the three families and what each optimises for.