System Designhard12 min read

Design a Virtual DOM

Build a virtual DOM from scratch, one small piece at a time: the node format, rendering to real DOM, diffing two trees, patching only what changed, and why keys matter. The reasoning interviewers want to hear, with code you can actually run.

Published · by Frontend Masters India

"Design a virtual DOM" is one of those prompts that sounds like trivia and turns out to be a real systems question. You're being asked: when the data changes, how do you update the screen without throwing the whole page away and rebuilding it? Get that right and a lot of React stops feeling like magic.

The trap is to start reciting "React uses a virtual DOM and a diffing algorithm." That's the headline, not an answer. What follows is the way I'd actually build it in an interview — small enough pieces that each one fits in your head, and you can run them.

Why bother with a virtual DOM at all

Touching the real DOM is slow, but not for the reason people repeat. A single el.appendChild isn't expensive. The cost comes from doing it carelessly: reading layout, writing, reading again, triggering reflow after reflow. And the harder problem is figuring out what to change. If your state goes from a list of 10 items to a list of 11, you don't want to wipe all 10 and re-create them. You want to add one node and leave the rest alone.

So the virtual DOM is two ideas, and it helps to keep them separate:

  1. A cheap description of the UI as plain JavaScript objects.
  2. A way to compare the new description against the old one and apply only the difference.

The objects are cheap to throw away and recreate on every render. The real DOM is expensive, so you touch it as little as possible. Everything else is detail.

1. Describe a node as a plain object

Start with the data structure. A virtual node is just type, props, and children:

function h(type, props, ...children) {
  return { type, props: props || {}, children: children.flat() };
}

That's it. h("div", { id: "app" }, "hello") gives you { type: "div", props: { id: "app" }, children: ["hello"] }. JSX compiles down to calls exactly like this — <div id="app">hello</div> becomes h("div", { id: "app" }, "hello"). Knowing that connection is worth saying out loud; it shows you understand JSX isn't a separate thing, just sugar over function calls.

Strings stay as strings — those are text nodes. Everything else is an element node. We'll lean on that distinction in a second.

2. Render the description to real DOM

Now turn one of these objects into an actual DOM node. This is the "mount" step — first paint, nothing to compare against yet:

function createDom(vnode) {
  // A string vnode is a text node.
  if (typeof vnode === "string" || typeof vnode === "number") {
    return document.createTextNode(String(vnode));
  }

  const el = document.createElement(vnode.type);

  for (const [name, value] of Object.entries(vnode.props)) {
    setProp(el, name, value);
  }

  for (const child of vnode.children) {
    el.appendChild(createDom(child));
  }

  return el;
}

setProp is where the fiddly bits live — event handlers, the className vs class mismatch, boolean attributes. Keep it small for now and mention you'd grow it:

function setProp(el, name, value) {
  if (name.startsWith("on") && typeof value === "function") {
    el.addEventListener(name.slice(2).toLowerCase(), value);
  } else if (name === "className") {
    el.setAttribute("class", value);
  } else {
    el.setAttribute(name, value);
  }
}

At this point you can already render something. The trick is everything after this, when the data changes a second time.

3. The naive update, and why it's wrong

The lazy version: on every state change, build the new tree, blow away the container's contents, and re-render from scratch.

container.innerHTML = "";
container.appendChild(createDom(newTree));

It works. It's also exactly what we set out to avoid. You lose focus on inputs, you reset scroll position, you discard any DOM state the browser was holding, and you do O(n) work for a one-node change. This is the moment to say: "That's the baseline. The whole point of a virtual DOM is to not do this." Then show the alternative.

4. Diff two trees, patch the difference

Here's the core. Walk the old tree and the new tree in parallel and decide, node by node, the smallest change that gets from one to the other. There are four cases, and naming them clearly is most of the battle:

  • Node added — old is missing, new exists. Create and append.
  • Node removed — old exists, new is missing. Remove it.
  • Node changed typediv became span. You can't morph one into the other, so replace it wholesale.
  • Same type — keep the DOM node, update its props, then recurse into children.
function diff(parent, oldVNode, newVNode, index = 0) {
  const existing = parent.childNodes[index];

  // Added.
  if (oldVNode == null) {
    parent.appendChild(createDom(newVNode));
    return;
  }

  // Removed.
  if (newVNode == null) {
    if (existing) parent.removeChild(existing);
    return;
  }

  // Changed — different type, or text content differs.
  if (changed(oldVNode, newVNode)) {
    parent.replaceChild(createDom(newVNode), existing);
    return;
  }

  // Same element type: update props, then recurse into children.
  if (newVNode.type) {
    updateProps(existing, oldVNode.props, newVNode.props);

    const max = Math.max(oldVNode.children.length, newVNode.children.length);
    for (let i = 0; i < max; i++) {
      diff(existing, oldVNode.children[i], newVNode.children[i], i);
    }
  }
}

function changed(a, b) {
  return (
    typeof a !== typeof b ||
    (typeof a === "string" && a !== b) ||
    a.type !== b.type
  );
}

Notice the shape of the decision. The comparison runs on plain objects, which is cheap, and a real DOM mutation only happens when something genuinely differs. Compare in JavaScript, touch the DOM sparingly. That's the whole value proposition, and if you say nothing else in the interview, say that.

5. Update props without nuking them all

When two nodes are the same type, don't reset every attribute. Remove the ones that disappeared, set the ones that are new or changed:

function updateProps(el, oldProps, newProps) {
  // Remove props that no longer exist.
  for (const name in oldProps) {
    if (!(name in newProps)) el.removeAttribute(name);
  }
  // Set new or changed props.
  for (const name in newProps) {
    if (oldProps[name] !== newProps[name]) setProp(el, name, newProps[name]);
  }
}

This is also where event listeners get tricky in a real implementation — you'd need to remove the old handler before adding the new one, or you'll stack duplicates on every render. Worth flagging even if you don't write it.

6. The reconciliation shortcut interviewers want named

The diff above recurses through the entire tree. A truly minimal diff between two trees is an O(n³) problem — too slow to ship. React's move is to not solve the general problem at all. It makes two assumptions that turn O(n³) into roughly O(n):

  • Different types produce different trees. A div that became a span? Don't diff the subtrees, just replace. That's the changed branch above.
  • Children keep their identity across renders via a key. Without keys, the diff compares by position — and reordering a list looks like "every item changed." With stable keys, a reorder is recognized as a move, and the DOM nodes are reused.

This is the heuristic. Call it what it is: a deliberate trade-off, where the correctness of a truly minimal diff is given up for speed. Saying that out loud is the difference between having read the docs and having actually thought about it.

7. Why keys matter, concretely

Picture a list [A, B, C] and you prepend Z to get [Z, A, B, C]. Diffing by index, position 0 went from A to Z, position 1 from B to A, and so on — four "changes" for what was really one insertion. If A, B, C were inputs with text in them, the user's typing just jumped to the wrong rows.

Give each item a stable key and the diff matches A→A, B→B, C→C by identity and inserts Z. One DOM operation instead of four, and nobody's cursor moves. This is why key={index} is a quiet bug — when the list reorders, the index isn't stable, so you're back to diffing by position. Use something tied to the data, like an id.

8. Wiring it into a render loop

The last piece is the cycle that ties state to screen. Keep the previous tree around, build the next one from current state, diff, and stash the new one as the old:

let prevTree = null;

function render(newTree, container) {
  if (prevTree == null) {
    container.appendChild(createDom(newTree)); // first mount
  } else {
    diff(container, prevTree, newTree);
  }
  prevTree = newTree;
}

Call render on every state change. The first call mounts; every call after that diffs and patches. That's a working virtual DOM — small, but the bones are real, and you can extend it toward components, hooks, or fragments from here.

What the interviewer will push on

  • "What's the time complexity of the diff?" Naive tree diff is O(n³); the heuristics (type-based replacement, keyed children) bring it to O(n). Have that number ready.
  • "What does key={index} break?" Reordering and insertion. The index isn't tied to the item, so the diff misattributes changes.
  • "Where does this fall down versus React's real implementation?" No fragments, no component boundaries, synchronous and unbatched, and the recursive diff can block the main thread on a big tree. React's fiber architecture exists to make that work interruptible — a good place to point if you have time.
  • "Is the virtual DOM always faster than direct DOM updates?" No, and saying so scores points. Hand-written, targeted DOM updates beat it. The virtual DOM wins on developer ergonomics at scale — you write declarative code and it figures out the minimal change — not on raw speed.

The one-paragraph summary

A virtual DOM is a cheap JavaScript description of the UI plus a diff that turns the old description into the new one with the fewest real DOM mutations. Build it in layers: a node format (h), a renderer (createDom), a diff that handles add/remove/replace/update, prop reconciliation, and keys to preserve identity across reorders. Lead with the core idea — compare in JavaScript, touch the DOM sparingly — name the O(n) heuristics as a conscious trade-off, and be honest that it's about ergonomics, not always speed.

Before you leave — how confident are you with this?

Your honest rating shapes when you'll see this again. No grades, no shame.

Comments

to join the discussion.

Loading comments…

More design prompts