Anup Shinde
Go

How godom's virtual DOM works

April 30, 2026 10 min read

How godom builds a virtual DOM tree in Go, diffs it against the previous tree, and ships only the patches the browser needs to apply.

Two VDOM trees side by side, previous and next, with the changed node highlighted in pink and the resulting PatchText shown below.
One state change becomes one PatchText. Same node ID, new text, applied by the bridge.

TL;DR

  • godom’s virtual DOM is a small Go package (internal/vdom/) with five node types, a template parser, a resolver that turns templates into concrete trees, a differ that produces minimal patches, and a merger that updates the old tree in place.
  • Every node carries a stable numeric ID from a counter that never resets, so patches address the bridge’s existing DOM nodes by ID and never by path.
  • The first frame ships the full tree (SERVER_INIT). Every render after that ships only the diff (SERVER_PATCH).
  • Phoenix LiveView is the closest cousin; the model also echoes Elm’s “view is a function of state.”

In why I built godom , I described the architecture in one sentence: Go owns the DOM and the browser is a screen. This post is the part I glossed over. How that ownership actually works.

The virtual DOM is the part of godom that everything else hangs off. The bridge has nothing to render until the VDOM exists. The wire protocol has nothing to encode until the diff produces a patch. The template directives are pointless without the resolution step that turns them into a tree. So this is the walkthrough I should have written first.

The shape of the tree

Five node types make up everything godom can render:

  • TextNode: leaf, just a string.
  • ElementNode: an HTML or SVG element with a tag, some metadata, and ordered children.
  • KeyedElementNode: same as ElementNode, but its children carry stable string keys so reorders don’t get treated as “everything changed.”
  • PluginNode: an opaque host element that a JS library renders into. Go computes the data, the plugin draws. Chart.js, ECharts, xterm.js all live here.
  • LazyNode: a deferred subtree. If the function pointer and arguments are reference-equal to last render, the whole subtree is skipped.

That’s the whole vocabulary. Almost everything in a godom view resolves to one of these.

Facts: element metadata in one place

Five flavors of metadata can attach to an element, and they all live on a single struct called Facts:

type Facts struct {
    Props   map[string]any          // DOM properties: className, value, checked
    Attrs   map[string]string       // HTML attributes: data-*, aria-*, role
    AttrsNS map[string]NSAttr       // Namespaced (SVG): xlink:href, xml:lang
    Styles  map[string]string       // Inline CSS: background-color, width
    Events  map[string]EventHandler // Event listeners: click, input, keydown
}

Why group them? Because the differ then walks all of them in a single pass and produces a FactsDiff containing only what changed. If just className flipped from active to inactive, the patch carries Props: {className: "inactive"} and nothing else. The browser side gets one patch and applies one DOM operation.

The events deserve a note. They’re declarative inside Facts. g-click="Save" becomes an entry in Events keyed by click whose handler name is "Save". The bridge reads that, attaches a listener, and when the user clicks it sends a small message back saying “node 42 fired click; the handler name is Save.” There is no JS expression to evaluate, no callback to construct.

Two phases: parse once, resolve every render

godom templates are HTML with a g-* directive layer:

<div>
  <h1 g-text="Title"></h1>
  <p>Welcome, {{Name}}!</p>
  <ul>
    <li g-for="item in Items" g-key="item.ID">
      <span g-text="item.Text"></span>
      <button g-click="Remove(item.ID)">Delete</button>
    </li>
  </ul>
  <p g-if="ShowFooter">That's all!</p>
</div>

This file is parsed exactly once, when the island registers. ParseTemplate walks the HTML, extracts g-* directives into a Directive slice, parses {{expr}} interpolations into a list of static-or-expression parts, and returns a tree of *TemplateNode. From that point the template tree is immutable.

Every render cycle, ResolveTree walks that tree against the current state of your Go struct and produces a fresh []Node. That’s an actual VDOM tree with concrete values, unrolled loops, evaluated conditionals, and assigned IDs. Field accesses, dotted paths, and zero-arg methods go through a fast reflection path. Anything with operators (==, >, and, or, not) goes through expr-lang with the compiled program cached.

Splitting parsing from resolution matters more than it looks. The structure is fixed at startup; only the data changes per render. The differ later relies on this assumption: old and new trees always have the same shape, so positional diffing is correct.

Stable IDs, and why they matter

Every node in a resolved tree gets a unique numeric ID. Those IDs come from a single monotonic IDCounter that lives on the engine and is threaded into every island. It increments forever, never resets across renders, and produces IDs that are unique across every island in the engine.

That choice carries the whole architecture. The bridge in the browser keeps a nodeMap from ID to real DOM node. Every patch the server emits points at a node by ID. The bridge looks up the DOM node, applies the patch, and never has to think about positions or paths or ancestry. nodeMap[42] is the same DOM node it was ten renders ago.

Resolving a fresh tree on every render would mint new IDs for every node, which would wipe the bridge’s lookup table on every change. To avoid that, godom has MergeTree. After diffing, MergeTree(oldTree, newTree) walks both trees in lockstep and updates the old tree’s nodes in place: same structural slot, keep the old ID, absorb the new data. Only nodes that are genuinely new (an <li> appended at the end of a list, a previously-hidden g-if block coming back) get fresh IDs from the counter.

The next render’s diff then operates on the same nodes the bridge knows about. No drift, no re-keying, no full retransmits.

The diff

Diff(oldTree, newTree) returns a flat list of patches. Each patch has a type, a target NodeID from the old tree (because that’s what the bridge has), and a type-specific payload.

The patch types are:

TypeEmitted when
PatchRedrawNode type changed, or an element’s tag or namespace changed. Full subtree replacement.
PatchTextA TextNode’s text changed.
PatchFactsAn element’s props/attrs/styles/events changed. Carries only the diff.
PatchAppendNew children appended at the end.
PatchRemoveLastN children dropped from the end.
PatchReorderKeyed children inserted, removed, or moved.
PatchPluginA PluginNode’s Data changed (compared by JSON value).
PatchLazyWrapper for patches inside a LazyNode’s subtree.

Because the template tree is fixed at parse time, resolved old and new trees always share the same skeleton; positional diffing is correct without any extra matching step. The two places where shape can shift are keyed lists and g-if blocks, and both are handled explicitly.

Keyed children

For non-keyed children, “same position, same identity” is the rule. Position 3 in the old tree is position 3 in the new tree, full stop. If the new list has more children, the tail becomes a PatchAppend. If it has fewer, the old tail becomes a PatchRemoveLast.

That breaks down the moment you actually move things around. Reordering the first item to the end without keys would emit “node 1 changed, node 2 changed, … append one, remove last.” KeyedElementNode solves this. Each child has a stable string key. The differ runs a keyed match: same key means same node (kept its ID, gets diffed); missing key means removed; new key means inserted. Moves come out as a PatchReorder with the minimum set of inserts, removes, and patches.

In a template, the difference is one attribute: g-for="item in Items" (positional) vs g-for="item in Items" g-key="item.ID" (keyed). The key changes the data structure of the resulting tree, which changes how the differ behaves. That’s the whole effect.

Lazy nodes

Most subtrees re-resolve every render. A LazyNode doesn’t, if its inputs haven’t changed.

A lazy node holds a function pointer and an args slice. Before resolving its body, the differ asks: are the function pointer and every arg reference-equal to last time? If yes, the previous resolved subtree is reused as-is. If no, the function runs, the body resolves, and the diff continues normally.

Reference equality, not value equality. This is deliberate: it’s free, it’s predictable, and it lets a developer opt in by passing the same slice/struct/pointer between renders. Big static sidebars, settings panels, anything expensive to resolve and rarely changing, wrap them, hand them stable inputs, and the renderer skips them.

A walkthrough of one render

godom expects state to change in exactly two ways. Either the user does something (a click, an input, a key) and a method on the island struct runs, or the server does something on its own (a goroutine ticks, a stream pushes, a job finishes) and a struct field gets mutated directly. Both paths land in the same place: the framework re-resolves the template against the new state, diffs the result against the previous tree, and ships only the patches the bridge needs to apply. Because the template structure is fixed and the only thing that can shift between renders is the struct’s field values, the diff stays small and predictable. There’s no “anything could have changed anywhere” worst case to defend against.

The animation below traces the user-input path. The server-side path follows the same shape: mutate a field in Go, the next render finds the changed node, the patch goes out.

RENDERED UI (BROWSER) Count: 0 1 + PREVIOUS TREE DIFF NEXT TREE div id: 1 p id: 2 "Count: " id: 3 span id: 4 "0" "1" id: 5 button id: 6 "+" id: 7 STATE CHANGE Count: 0 → 1 div id: 1 p id: 2 "Count: " id: 3 span id: 4 "1" id: 5 button id: 6 "+" id: 7 PATCH ON THE WIRE Patch{ Type: PatchText, NodeID: 5, Data: {Text: "1"} } one node changed; one patch ships; the bridge looks up nodeMap[5] and sets its text content.
One state change becomes one PatchText.

Here’s the tiny island and template behind that animation:

type Counter struct {
    godom.Island
    Count int
}

func (c *Counter) Increment() { c.Count++ }
<p>Count: <span g-text="Count">0</span></p>
<button g-click="Increment">+</button>

When the page first loads, ParseTemplate runs once and produces a *TemplateNode tree. Then ResolveTree walks the template against Counter{Count: 0}. Each node gets an ID. The server sends SERVER_INIT with the resolved tree. The bridge builds the real DOM and fills nodeMap.

Now the user clicks the button. The bridge sends back: “node N fired click, handler Increment.”

The server dispatches. Increment() runs; c.Count is now 1. The framework re-resolves the template against the new state. The new tree’s text node carries "1" instead of "0". Diff walks both trees. The only difference is the text inside the <span>. The result is one PatchText targeting the old text node’s ID, payload Text: "1". MergeTree updates the old tree in place so the IDs survive into the next render. The server ships the patch as SERVER_PATCH. The bridge looks up the DOM node, sets its text content. Done.

On the wire, the entire interaction is one event message in, one tiny patch out.

For high-frequency cases where re-resolving the whole template starts to cost (a dashboard with thousands of nodes, a ticker updating one field), the island layer above the VDOM offers MarkRefresh(fields...) to scope the rebuild before the diff runs. That sits a layer above this package, not inside it.

What it isn’t, and what it nearly is

It’s easy to look at this and call it React’s reconciler in Go. It isn’t quite. React’s matching rules are actually similar in family (same type keeps the node, different type rebuilds, children match by position or by key). The differences are elsewhere. React builds a fresh element tree in its runtime on every render and reconciles it against the previous one, wherever it’s running (browser, SSR, React Native). Modern React schedules that work concurrently via Fiber and, with the React Compiler, can skip components whose inputs haven’t changed. godom’s tree is built in Go on the server, the template is parsed once at startup so the resolved tree’s shape is constrained by that template, and the render loop is plain synchronous: change state, re-resolve, diff, ship the patch.

It’s also not Svelte. Svelte compiles your template into bespoke imperative DOM updates at build time; there is no general-purpose virtual DOM at runtime. godom keeps a real VDOM in memory and diffs trees.

The closest cousin is Phoenix LiveView. The server owns the tree, the browser is thin, patches stream over a persistent connection. godom isn’t a clone of LiveView. Different language, different wire format (binary protobuf over a single WebSocket vs LiveView’s HTML-fragment approach), different scope (single-process single-user, not a multi-tenant web framework). But the shape is recognizable.

The other shadow is the Elm Architecture. Not the syntax, not the runtime, but the model: state lives in one place, the view is a function of state, and a render cycle is “state changed, recompute the view, reconcile against the previous view.” That’s what every godom render does. The state is a Go struct instead of an Elm record, the diff target is a real-DOM bridge instead of Elm’s runtime, but the shape rhymes.

godom isn’t trying to be original here. The VDOM choices are conservative on purpose. The interesting part is the constraint above all of them: the bridge has to stay dumb, so the tree has to be structurally simple and the patches have to be small enough that a thin JS client can apply them without making decisions.

Closing thought

Most of what you read above isn’t new. Stable IDs, monotonic counters, keyed list diffing, parse-once resolve-per-render: standard moves in the VDOM literature. What’s specific to godom is where the tree lives. It lives in Go, on the server side of a localhost WebSocket, and the browser is told what changed in just enough detail to do the DOM work.

The next post in this set is about how those patches actually get encoded and shipped: why binary Protocol Buffers, and how Browser→Go traffic is split between input events and explicit method calls.

If you want the full code, the reference is at github.com/anupshinde/godom/blob/main/internal/vdom/README.md. The package is small enough to read in one sitting.