Skip to content
yutils

How the Virtual DOM Actually Works

VDOM as a snapshot, reconciliation diff (O(n) not O(n³)), React Fiber's interruptible scheduler, why the key prop matters, Vue's tracked-dep patch, and Svelte / Solid's no-VDOM signal approach.

~9 min read

React's "JSX → DOM auto-updates" magic = virtual DOM + reconciliation. Yet modern frameworks (Svelte / Solid) do the same magic without a VDOM. This guide covers how VDOM works, why the diff is O(n) instead of O(n³) (a heuristic), why the key prop matters, and the no-VDOM alternative path.

What VDOM Is

Core idea: don't mutate the DOM directly — describe the desired shape as a JS object.

JSX:
  <div className="card">
    <h1>{title}</h1>
    <p>{body}</p>
  </div>

→ After compilation:
  React.createElement("div", {className: "card"}, [
    React.createElement("h1", null, title),
    React.createElement("p", null, body)
  ])

→ JS object at runtime (the VDOM tree):
  {type: "div", props: {className: "card"}, children: [
    {type: "h1", props: {}, children: [title]},
    {type: "p", props: {}, children: [body]}
  ]}

State change → new VDOM tree (a different object from the previous one)
→ React diffs new vs old (reconciliation) → only the actual DOM
diffs are applied.

Why VDOM — Compared to Direct DOM

Direct DOM (jQuery era):
  $("#list").empty();
  for (let item of items) {
    $("#list").append("<li>" + item.name + "</li>");
  }

Problems:
- 100-item list → 100 DOM mutations
- One-item change still re-creates all (or you write a manual diff)
- jQuery is lightweight but app complexity ↑

VDOM's answer:
  function List({items}) {
    return <ul>{items.map(i => <li>{i.name}</li>)}</ul>;
  }

- React diffs automatically
- 1 changed item among 100 → 1 DOM mutation
- App code is "re-render everything" — declarative

Reconciliation Diff — O(n) Heuristic

The general tree-diff algorithm is O(n³) — exact minimum edit
distance between two large trees. 1000-node tree = 10^9 ops → too slow.

React's trick — two heuristics to reduce to O(n):

1. Different element types → different subtrees
   <div>...</div>  →  <span>...</span>
   → remove the entire div + build the span fresh (no internal diff)

2. The key prop signals list reorder
   Without key: <li>A</li><li>B</li><li>C</li> → <li>B</li><li>C</li>
                → React thinks A→B, B→C, C→remove (3 mutations)
   With key:    <li key="a">A</li><li key="b">B</li><li key="c">C</li>
                → remove A, keep B and C (1 mutation)

→ Give up "exact" + fit common usage patterns = fast dev experience.

Importance of the Key Prop

Common anti-pattern:
  {items.map((item, i) => <Card key={i} data={item} />)}
                                  ↑
                                index as key

Problem: on reorder, key=0 always points to the first item.
        items [A, B, C] → [B, C] (A removed):
          key=0: was A → now B (re-rendered with B's data)
          key=1: was B → now C
          key=2: was C → unmounted
        → All three Cards' props changed → re-render
        (only C's position actually unmounted)

Correct pattern:
  {items.map(item => <Card key={item.id} data={item} />)}
                            ↑
                          unique stable id

→ React knows precisely: A removed, B and C unchanged → B and C
  keep the same Card instance.

Especially important for stateful components like form inputs.

React Fiber — Interruptible Scheduler (2017+)

React 16's new reconciliation engine.

Old (Stack Reconciler):
  Once render starts, runs synchronously to the end
  → Large-tree render holds the main thread → ignores user input (jank)

Fiber:
  Render is split into "interruptible" units
  → Main thread can yield for other work (animation, input)
  → Priority system (high: user input / low: data update)
  → Foundation for React 18 "concurrent rendering"

Properties:
- One render can span multiple frames
- Urgent updates (input) preempt lower-priority updates (data)
- Foundation for Suspense / startTransition / useDeferredValue

→ The "60fps even on large pages" promise of modern React.

Vue — Tracked Dependency Patch

Vue's reactivity takes a different approach:

  const state = reactive({count: 0});
  console.log(state.count);  // get → Vue tracks the dependency

  function Counter() {
    return <div>{state.count}</div>;  // dep registered
  }

  state.count++;  // set → only components depending on it re-render
                  // (others unchanged)

→ React diffs the whole tree; Vue patches exactly what changed.
   Different runtime cost — Vue is fine-grained with tracking overhead.

Vue 3's VDOM is also smaller than React's + Proxy-based reactivity is
more efficient.

Svelte / Solid — No VDOM (Signals)

Another path: remove VDOM entirely.

Svelte:
  <script>
    let count = 0;
  </script>
  <button on:click={() => count++}>{count}</button>

→ At compile time, generates exact DOM commands like "update that
  button's textContent when count changes".
→ No runtime VDOM diff; mutate DOM directly.
→ Very small bundle (framework runtime near zero).

Solid:
  function Counter() {
    const [count, setCount] = createSignal(0);
    return <button onClick={() => setCount(count() + 1)}>{count()}</button>;
  }

→ JSX-based but internally uses signals — fine-grained reactivity like Vue.
→ Familiar to React users; performance close to Svelte's.

Trade-off:
- React/Vue: larger framework runtime, stable mental model
- Svelte/Solid: tiny runtime, new paradigm

Performance — Is VDOM Always Slower?

Common myth: "VDOM is expensive → direct DOM is faster"

In reality:
- Small updates: direct DOM ≈ VDOM (both = 1 mutation)
- Large updates: VDOM can batch + apply once → sometimes faster than direct
- Complex reorder: nearly impossible directly (manual diff needed)

→ "The VDOM cost" itself is small (tens of μs). Real cost is what
   happens inside the render function.

VDOM's value:
1. Developer experience (declarative)
2. Automatic batching + scheduling
3. Cross-platform (React Native — VDOM mapped to native widgets)
4. Testing / DevTools (tree snapshots)

Common Pitfalls

  • index as key — wrong instance reuse on reorder / add / remove → state leakage, perf loss.
  • Inline objects / functions as props — fresh references per render → memoized children become useless. Use useCallback / useMemo.
  • Large components rendering often — even Fiber can't split a single component's render. Break them up.
  • Overusing useMemo / useCallback — memoization has cost. Measure first.
  • Only worrying about VDOM cost — real bottlenecks are usually big state / big lists / poor component split.

Wrap-up

VDOM at its core — "diff the desired (JS object) against actual DOM". React's heuristics (different type → different subtree, key for reorder) take O(n³) → O(n). Fiber keeps 60fps with interruptible rendering.

Practical — React/Vue VDOM is plenty fast on modern hardware. For smaller bundle / runtime, the no-VDOM signal approach of Svelte / Solid. Either way, correct key prop usage + small components give you 80% of performance.

Back to guides