Anup Shinde
Go

The thin bridge: keeping bridge.js dumb on purpose

May 9, 2026 11 min read

godom's bridge.js builds DOM, applies patches, and forwards events. It does not evaluate expressions, hold state, or make decisions. The constraint is the feature.

A diagram of bridge.js as a dispatcher: an inbound WebSocket carries an init tree and a list of patches; the bridge runs an eight-case switch on the patch op (text, facts, append, remove-last, reorder, redraw, plugin, lazy) and applies each. An outbound WebSocket carries BROWSER_INPUT and BROWSER_METHOD. A side panel labelled 'not in this file' lists: expression evaluation, state, debouncing, business logic.
An eight-case switch on the inbound side. Two message kinds on the outbound side. Everything else lives in Go.

TL;DR

  • bridge.js is the one JS file godom ships. Its job is to build DOM from a tree, apply patches by node ID, and forward events back to Go. That’s it.
  • Inbound from Go: an eight-case switch on the patch op (text, facts, append, remove-last, reorder, redraw, plugin, lazy). Outbound to Go: two kinds (BROWSER_INPUT, BROWSER_METHOD).
  • The bridge does not evaluate expressions, look up app state, debounce or batch on its own behalf, or decide what to render. Those would all be the framework re-emerging in the browser.
  • Holding this line was the hardest single thing I had to keep correcting with AI in the early weeks. What eventually made the constraint self-enforcing wasn’t more rules, it was the protocol getting tight enough that “make the bridge smarter” stopped looking reasonable.

The previous four posts covered Go’s side: the VDOM , the wire protocol , composition , and the JS surface for apps . This one is about godom’s own JS, the bridge that ships with the framework.

What the bridge does

The bridge has a small contract. On SERVER_INIT, parse a tree description and build the corresponding DOM. On SERVER_PATCH, run patches against the DOM by node ID. On user events (clicks, inputs, scrolls), encode a BrowserMessage and send it back.

The inbound side is a switch on the patch op, eight cases total:

switch (patch.op) {
    case "redraw":      execRedraw(node, patch); break;
    case "text":        execText(node, patch); break;
    case "facts":       execFacts(node, patch); break;
    case "append":      execAppend(node, patch); break;
    case "remove-last": execRemoveLast(node, patch); break;
    case "reorder":     execReorder(node, patch); break;
    case "plugin":      execPlugin(node, patch); break;
    case "lazy":        applyPatches(patch.subPatches); break;
}

Each handler is short. execText sets nodeValue or textContent. execAppend decodes the inline subtree, calls buildDOM, appends. execRemoveLast does node.removeChild(node.lastChild) in a loop. Nothing computes; nothing decides.

DOM construction is the other half. buildDOM(tree) is a recursive switch on node type that creates an element, applies its facts (props, attributes, namespaced attributes, styles, events), registers any inline events, and recurses into children. Each node carries its server-assigned ID; the bridge writes it into nodeMap[id] and onto the DOM element as _godomId so future patches and cleanups can find it.

The outbound side has even less surface. sendNodeEvent produces a BROWSER_INPUT with a node ID and a string value (used for input/textarea/select changes). sendMethodCall produces a BROWSER_METHOD with a node ID, method name, and an arg list (used for g-click, g-keydown, etc.). Two encoders, one socket, done.

What the bridge does not do

The shape of the file is “build, apply, forward.” Several things are deliberately absent.

No expression evaluation. The browser never sees g-text="Greeting()" or g-show="Count > 0". Those are resolved on the Go side during render; what arrives at the bridge is plain text or a presence/absence in the tree. The bridge has no parser, no eval, no scope lookup, no field resolver.

No state. The bridge holds DOM references for patching (nodeMap), per-island plugin tracking (pluginState), and connection bookkeeping (the WebSocket, target contexts, the disconnect overlay). That’s plumbing. There is no copy of the app’s state, no shadow store, no cached version of a struct field.

No business decisions. The bridge does not choose what to refresh, what to throttle, what to keep, what to drop. Those decisions are framework-level and they live in Go. The bridge applies whatever the server sent.

Almost no batching of its own. There are exactly two cases where the bridge throttles before sending. mousemove and scroll are coalesced through requestAnimationFrame so a continuous drag doesn’t flood the socket once per pointer move. Everything else (keystroke for g-bind, click, keydown) goes out as it happens. The server decides what to do with the storm.

If you’re looking for the “smart” JS framework parts (a template language, a reactive runtime, a scheduler, a context system), they aren’t here. They’re in the Go process. The bridge is downstream of all of that.

What the bridge looks like in practice

The whole file is about 950 lines. Nine sections:

  1. State and globals.
  2. Connection: the WebSocket with auto-reconnect and a disconnect overlay.
  3. Target management: per-island encapsulated contexts (each island gets its own nodeMap and pluginState).
  4. DOM construction: buildDOM and helpers.
  5. Patch execution: the switch above and its handlers.
  6. Facts application: a small dispatch over the diff record’s p / a / an / s / e fields (props, attrs, namespaced attrs, styles, events).
  7. Event handling: a single registerSingleEvent that wires up addEventListener for whatever the tree says, plus the input-sync and drag-and-drop auto-registration.
  8. Helpers.
  9. Plugin registration: godom.register(name, handler) and godom.mount(name, element).

The largest single behavior in the file is event handling, because that’s where the bridge has to translate browser events into something Go can act on. Even there, the logic is “pull the relevant fields off domEvent, encode them as args, send.” The keymap (Enter:Submit, ArrowUp:Up;ArrowDown:Down) is resolved on the Go side and arrives as a per-event key filter; the bridge does no key parsing of its own beyond comparing domEvent.key.

A few things in the file are not strictly “apply a patch,” but they all fit the role of a sync engine talking to a browser. The disconnect overlay (full-page when godom owns the page, per-island badges in embedded mode). Auto-reconnect with backoff. Pull-based init via BROWSER_INIT_REQUEST when a new [g-island] element shows up. ExecJS support for the Go-to-browser request/response path. Shadow DOM scanning for g-shadow islands. None of these involve a decision about what the app should render; they’re all wire-level concerns.

The arc: it kept getting smarter, and I kept making it dumber

The AI-build journal mentions this in one paragraph; this is the longer version.

The thin-bridge principle is load-bearing. If state lives on the Go side and the bridge starts holding its own version of any of it, the framework’s pitch quietly collapses. You end up with the exact “state on two sides, kept in sync by friction” problem that godom exists to escape.

In the early weeks, the bridge was the single place this kept fraying. Each individual suggestion from Claude looked reasonable.

  • “Cache the last rendered value of this field so we don’t re-set it if it didn’t change.” That sounds like an optimization. It is also a shadow store, with its own staleness windows and cache invalidation.
  • “Debounce these inputs before sending; you don’t want every keystroke crossing the wire.” This sounds like courtesy to the network. It is also a policy decision the server is better placed to make, and it makes multi-tab sync subtly wrong if two tabs debounce independently.
  • “Resolve this expression in the browser for the immediate feedback path.” This sounds like good UX. It is also a parser, a scope, and a new place for behavior to disagree with what Go thinks.
  • “Track which elements have been rendered into and avoid double-renders on reconnect.” This sounds like robustness. It is the bridge starting to model state.

I had to push back on these many times, often inside the same session. The pattern was so consistent that the words “I want this dumb” became part of my standard prompt vocabulary. None of the suggestions were wrong as JS-framework moves; they were wrong for this framework, where the value comes from there being exactly one source of truth and exactly one place a decision happens.

What changed it wasn’t more rules in the prompt. It was the architecture maturing on the Go side. Once the protocol was tight and the patch types were small and well-named, “the bridge should also do X” started looking obviously off. If a new requirement showed up, the right shape was usually a new patch op or a tweak in the diff, not a hand-rolled behavior in bridge.js. The constraint moved from being a thing I had to enforce, to a thing the code visibly already enforced.

You can see it in the commit history. The bridge does not just get features added to it; it gets simpler in places.

  • 48366c6 simplified focus restore. The previous version saved focus and selection via nodeMap lookups and re-applied them through ID; the new version just compares document.activeElement before and after patching, restores only if it changed, and skips focus work entirely for facts-only patches.
  • dc79c24 cached ServerKind / BrowserKind enum lookups at the top of the file instead of re-deriving them per message.
  • 2563a99 unified two init branches and deleted an unused variable.

None of these are dramatic. That’s the point. The bridge is the boring part. Every cleanup commit on it makes it a little more boring.

When the bridge had to learn something new

The bridge has gained capabilities over time. Each one earned its place.

  • Pull-based init for dynamic mounts. When a new g-island element appears in the DOM after the initial render, the bridge sends a BROWSER_INIT_REQUEST and the server responds with that island’s tree. This is a wire-level conversation; it does not involve the bridge knowing what an island is, only that an element with g-island="name" needs a tree.
  • Shadow DOM scanning. When an island target carries g-shadow, the bridge attaches an open shadow root and renders into it. Subsequent scans for [g-island] cross those shadow roots so nested islands inside a shadow tree get found. The decision to use a shadow root is the server’s; the scan is the bridge’s plumbing.
  • mousemove and scroll throttling via requestAnimationFrame. This is the one case where the bridge does its own coalescing. It’s about an upper bound on socket traffic during a continuous drag, not about app behavior. The throttled events still arrive at the server in order with their latest values.

Each of these existed in some earlier form before settling into its current shape. The pattern is the same: a feature shows up, the first version has more JS than it needs, then the JS shrinks once the Go side learns to express the thing protocolly.

ExecJS and godom.call: the two doors through the bridge

Most of the bridge is one-way patches in and events out. Two capabilities punch through that.

ExecJS is Go asking the browser to evaluate a JavaScript expression and return the result. From Go:

a.ExecJS("({url: location.href, vw: window.innerWidth})", func(result []byte, err string) {
    if err != "" { return }
    var info struct {
        URL string `json:"url"`
        VW  int    `json:"vw"`
    }
    json.Unmarshal(result, &info)
    a.BrowserURL = info.URL
    a.Refresh()
})

The bridge receives a SERVER_JSCALL with an expression and a call ID. It evaluates the expression with an indirect eval (global scope), JSON-serializes the result, and ships a BROWSER_JSRESULT back. Per connected browser, you get one callback invocation. The bridge’s job is the trip; the decision about what to ask and what to do with the answer is the server’s.

The use case where this earns its place most clearly is the one I sketched in the composition post : godom embedded in a host page you don’t fully own. A Chrome extension injecting godom.js into pages, an island sitting in someone else’s HTML, the embedded-widget pattern in examples/embedded-widget/. In those layouts, the host page is full of data that godom didn’t render: a price in a span, a query string, a JSON blob in a <script type="application/json">, the state of a form the user already filled in, the contents of a localStorage key.

ExecJS is how Go reaches into that page without growing a JS frontend to do it. One line:

a.ExecJS(`document.querySelector('.product-price').textContent`, ...)

and the value comes back to Go. The Go side decides what to extract, what to do with it, when to refresh. The bridge is the reach-in mechanism, nothing more. It still doesn’t know what a price is or why you wanted it.

godom.call is the other direction: a function the bridge exposes to arbitrary JavaScript running on the page. From a plain inline script, from a third-party widget, from a console:

godom.call("SelectItem", itemId);
godom.call("UpdateConfig", JSON.stringify({key: "value"}));

It encodes a BROWSER_METHOD with nodeId: 0 and the method name. The server dispatches it like any other method call. This is the inverse of ExecJS: instead of Go pulling, the host page pushes. Useful when the page has its own event sources (a native widget, a third-party library, a chrome extension content script) that need to hand state to the Go side.

Both of these stay inside the thin-bridge brief. The bridge is not parsing expressions of its own; it’s running an expression the server sent. The bridge is not deciding what method to call; it’s marshalling a call the page made by name. The smarts on each side belong to that side. The bridge is the wire.

Both can also be disabled, server-side (eng.DisableExecJS = true) or browser-side (window.GODOM_DISABLE_EXEC = true). When godom sits in a page you don’t fully trust, that flag is the difference between “Go can read from the page” and “Go cannot.”

Why this is the part that mattered

Bigger frameworks ship a smart browser runtime because that’s where they put the experience. godom’s pitch is the opposite. The Go process is the application. Everything that feels like “the framework” lives there: the parser, the diff, the directive validator, the reflection plumbing, the refresh logic, the partial expansion, the slot binding. The browser is downstream of all of it.

If the bridge starts making its own decisions, two things happen. One: the surface area to learn the framework doubles, because now there’s a JS-side behavior contract too. Two: the framework starts having opinions in two languages, and those opinions drift. The whole premise of “Go owns the DOM and there is no complex JS frontend to author” depends on the JS that godom does ship being uninteresting.

Boring JS is the feature. The bridge is the smallest possible thing that lets a browser act as a display for a Go program. Everything that makes godom feel like godom happens on the other side of the WebSocket.

Closing thought

The size of bridge.js is a useful internal-health metric too, not just an external one. If the file grows in a way that isn’t matched by something in the Go side getting more declarative, that’s the smell. A new feature that needs new bridge logic without a new patch op, a new directive, or a new piece of the wire protocol, is usually a feature whose shape is wrong.

That’s the whole series. VDOM , wire protocol , composition , JS surface , and now the bridge. Five posts about a framework whose pitch is, in the end, just “let Go own the DOM.” Most of the internals exist to defend that one sentence.