Back to blog
Sep 2026·3 min readAI-generated

How the Virtual DOM Actually Works

#javascript#react#frontend

"The virtual DOM makes React fast" is one of those phrases everyone repeats without quite being able to explain. It's not magic, and it's not even always faster than touching the real DOM directly — it's a specific trade-off that solves a specific problem: figuring out the minimal set of real DOM changes needed to go from one UI state to the next, without you having to compute that by hand.

The problem it solves

Direct DOM manipulation is expensive because the DOM does a lot of work per change — recalculating styles, layout, and potentially repainting. If your UI state changes and you don't know exactly what's different, the naive approach is to tear down and rebuild everything:

function render(state) {
  container.innerHTML = ''; // wipes everything, loses input focus/scroll position too
  container.innerHTML = renderHtml(state);
}

This works but is wasteful and destructive — form inputs lose focus, scroll position resets, CSS transitions restart. What you actually want is: change only the parts of the DOM that actually changed, and leave everything else untouched.

What a virtual DOM node actually is

A virtual DOM node is just a plain JavaScript object describing what an element should look like — no real DOM API calls involved yet.

// Roughly what React.createElement('button', { className: 'btn' }, 'Save') produces
const vnode = {
  type: 'button',
  props: { className: 'btn' },
  children: ['Save'],
};

A full tree of these objects is cheap to create — it's just object allocation, no browser rendering engine involved. That cheapness is the whole point: you can build a brand new tree on every render and it costs almost nothing, unlike rebuilding the real DOM.

Diffing: comparing old tree to new tree

When state changes, the framework builds a new virtual tree and compares it to the previous one, node by node. This comparison is called reconciliation, and a simplified version of the core algorithm looks like this:

function diff(oldVNode, newVNode) {
  if (oldVNode.type !== newVNode.type) {
    return { type: 'REPLACE', newVNode };
  }

  const propPatches = diffProps(oldVNode.props, newVNode.props);
  const childPatches = diffChildren(oldVNode.children, newVNode.children);

  return { type: 'UPDATE', propPatches, childPatches };
}

function diffProps(oldProps, newProps) {
  const patches = [];
  for (const key in newProps) {
    if (oldProps[key] !== newProps[key]) {
      patches.push({ key, value: newProps[key] });
    }
  }
  for (const key in oldProps) {
    if (!(key in newProps)) {
      patches.push({ key, value: undefined }); // removed
    }
  }
  return patches;
}

The output isn't a new DOM — it's a small list of patches: "this attribute changed," "this text node changed," "this element was added/removed." Only those patches get applied to the real DOM, via real, targeted element.setAttribute() / textContent = / appendChild() calls.

Why keys matter for lists

Diffing a list of children by array index breaks down the moment items get reordered, inserted, or removed from the middle. Without a stable identity per item, the algorithm assumes position 0 in the old array corresponds to position 0 in the new array — even if the actual item there changed entirely.

<!-- Without keys: reordering "B, A, C" makes the diff think every item's
     text content changed, when really only the order changed -->
<li>A</li>
<li>B</li>
<li>C</li>
// With a stable key, the algorithm can tell "this specific item moved"
// instead of "everything here is now different"
items.map((item) => ({ type: 'li', key: item.id, children: [item.name] }));

This is why every list-rendering API in every virtual-DOM framework asks for a key — it's not a formality, it's the piece of information the diffing algorithm literally cannot infer on its own.

What it costs

Building a full virtual tree and diffing it isn't free — for very simple, small UIs, or for updates you already know the exact shape of, direct DOM manipulation can genuinely be faster, because you skip the tree-building and comparison step entirely. The virtual DOM's actual win isn't raw speed on every single update; it's that it gives you a declarative programming model ("render this state as this tree") while still only touching the real DOM where necessary — without you manually tracking what changed.

That's also why frameworks have moved on from "virtual DOM diffing" as the only strategy. Compilers that know your template's structure ahead of time (Svelte, Angular's more recent template compilation) can skip runtime diffing entirely and generate direct update instructions at build time, because they don't need to discover what changed — they already know from the template.

Takeaway

A virtual DOM is a plain-object description of UI state, cheap to build and compare. Diffing two of those trees produces a minimal patch list, which is what actually touches the real DOM. It's not intrinsically "faster than the DOM" — it's a way to get a declarative rendering model without paying for a full rebuild on every change.

NewerCSS Container Queries: A Practical GuideOlderUnderstanding Web Workers for Heavy Computation