Why I built godom

23 min read Original article ↗

TL;DR

  • I built godom, a Go framework for local apps where the Go process owns the DOM and the browser is just a rendering surface.
  • No Electron, no webview embed, no complex JS frontend. go build produces a single binary that opens a browser tab and shows your UI.
  • Multi-tab sync, “state survives the browser close”, and single-binary distribution all fall out of the architecture, not from features I added.

The itch I kept scratching badly

I keep building small Go tools that need a UI. A backtest viewer. A scratch dashboard. A quick form to enter trade notes. An admin panel for something I’m running on a lab box. A system monitor for a headless machine.

Every time I needed a UI, I hated my options.

  • Electron ships an entire copy of Chromium. A 100MB binary for a tool that’s 4MB of actual logic feels insulting to the disk.
  • Tauri or Wails are smaller, but I’m still wiring up a JS frontend that talks to Go through bindings. Two languages, two build steps, two mental models.
  • Native GUI toolkits (Fyne, Gio, Qt bindings) are fine until you want anything that looks remotely modern. Cross-platform layout is its own quiet disaster.
  • A web app means a Go API, a JS frontend, JSON contracts between them, and the developer experience of “user clicked a button” expressed as POST /api/v1/buttons/clicked.

None of these are bad tools. They just weren’t built for what I keep building, which is small, local, single-user.

So I kept defaulting to the worst option of all: not building the UI. Just fmt.Println and a CSV.

And when the data really needed to be readable, I’d quietly upgrade. Generate a static HTML report with some inline CSS, write it to /tmp, open it in a browser, look at it once. The output got better. The input got worse.

Because here’s the other thing nobody warns you about: the moment a CLI tool grows past a few flags, you lose the plot. Six months later you come back, your own help text reads like a stranger wrote it, and you’re squinting at --input=parquet --window=15m --since=2024-01-01 --filter='symbol IN (...)' --output-html=true trying to remember which combinations actually do what you wanted. The tool isn’t broken. You forgot how to drive it. Now you have to read your own code just to use your own tool.

So “no UI” wasn’t actually free. A CLI that had grown complex enough to be useful was already a thing I needed a UI for. I just kept telling myself otherwise.

The other tax: dependency rot

There’s a second cost I never see when I’m shipping the first version, and it always comes back to bite me.

I’d build something with Node and a frontend framework, ship it, forget about it for six months. Come back to use it again. The first thing I’d do isn’t run the tool. It’s spend a day or two fixing security advisories, chasing breaking changes in transitive dependencies, and re-bisecting why a build that worked in May suddenly doesn’t in November. The actual feature I came back for? I haven’t even thought about it yet. I’m still fighting the toolchain.

Every framework has a half-life. You don’t notice it while you’re using the thing daily. You notice it the moment you abandon it for a while. And for personal tools, “abandon for a while” is the default state.

What I really wanted, somewhere in the back of my head, was the experience I had with AngularJS before all of this got complicated. ng-click="save()", ng-bind="name", declarative templates that bound to a model. The mental model was beautiful. The problem was always what came after the click: the API call, the server, the contract between the two halves. That’s where complexity reentered every single time.

The thing I actually wanted to eliminate: friction

If I had to compress every complaint above into one word, it’d be friction. Switching languages on the same feature. Designing a JSON contract for a thing that’s just “show this field on screen.” Maintaining two mental models of the same state. Two debuggers. Two builds. Two test stories.

The clearest version of this kept showing up the same way: I’d be in the middle of a JS file, writing some bit of logic, and realize I wanted that in Go instead. Or in Python, on the projects where Python was my backend. The data already lived in the backend language. The types already lived there. My tests already lived there. And there I was, translating the shape of an idea into JavaScript so a browser could show it, when the idea was already finished in another window.

I wasn’t trying to build a better UI framework. I was trying to not think about half the things every framework forced me to think about.

The insight that finally pushed me

Here’s the thing: every machine I deploy to already has a browser. Probably three of them.

Why am I packaging another copy of Chromium into every binary? Why am I writing a JS frontend when I’ve already written all my logic in Go? The browser is the best UI rendering engine I’m ever going to get; it just happens to live in a separate process.

The reframe was: stop trying to embed the browser. Use the one that’s already there. Have the Go process be the app, and treat the browser as the screen.

That’s it. That’s godom.

You write a Go struct. You put g-click="Save" in your HTML. The framework calls your Save() method. There is no API endpoint, no JSON, no complex JS frontend to author. You run go build, you get one binary, and when you start it, your default browser opens a tab pointing at localhost:<random-port> with a token. That tab is the window.

type App struct {
    godom.Island
    Count int
    Step  int
}

func (a *App) Increment() { a.Count += a.Step }
func (a *App) Decrement() { a.Count -= a.Step }

func main() {
    app := &App{Step: 1}
    app.Template = "ui/index.html"

    eng := godom.NewEngine()
    eng.SetFS(ui)
    log.Fatal(eng.QuickServe(app))
}
<h1><span g-text="Count">0</span></h1>
<button g-click="Decrement">−</button>
<button g-click="Increment">+</button>
<input type="number" g-bind="Step"/>

That’s a working, live, two-way-bound counter. It’s also a complete app.

I also didn’t want to invent a custom element library. I like React’s syntax in isolation, but I wanted plain HTML files for godom. I looked at templ, which is a clean piece of work, and a Go-typed view that compiles to a render function still wasn’t the motion I wanted to make. So godom uses plain HTML with a small layer of g-* directives that rhymes with early AngularJS, which felt like a breath of fresh air in JavaScript when it landed.

The constraint that made the whole thing work

I had to give something up to make this approach feasible. What I gave up was “general-purpose web framework.”

godom assumes the user and the process live on the same trusted network. One person, or a small group of people who already trust each other. The threat model is “stop the other people on this Wi-Fi from poking at my admin panel,” not “survive the open internet.” Token-based auth is on by default (and got more careful over time as I started running things on my LAN), but there’s no HTTPS, no role system, no rate limiting, none of the machinery a real public web app needs.

That’s not a corner I cut. It’s the constraint that makes everything else simple. The moment you have to handle thousands of users and adversaries, you should reach for a real web framework, and the architecture I’m describing falls apart. But for a single-user dashboard, a tool, a dev utility, a local backtest viewer, that’s not the shape of the problem.

Choosing the small problem on purpose was how I avoided rebuilding Rails.

What falls out for free

This is the part I didn’t expect.

When Go owns the DOM and the browser just renders, a few things start happening that I never explicitly built.

State survives the browser closing. Close the tab. Open a new one. The app is exactly where you left it, because the Go process never lost its state. The browser just reconnects and gets sent the current tree.

Multiple tabs stay in sync. Open the app in two windows. Type in one. The other updates as you type. I didn’t write sync code. Go owns the state and pushes DOM patches to every connected tab; that’s the whole mechanism.

The same trick works across devices. The “tabs” don’t have to be on the same machine. At one point I spent an afternoon building a tiny breakout clone where the phone is the controller (reading its gyro for paddle movement) and the laptop is the screen rendering the game. Nothing in the code says “remote control.” Both devices just connect to the same Go process; Go reads orientation events from one and broadcasts game state to the other. I started that experiment because I realized it would probably work. It did, on the first try. Multi-device co-presence is the same property as multi-tab sync, with nothing extra to write.

Single binary, no node_modules, no deployment ceremony. go build. Run. Stop. Done. The HTML, CSS, and the JS bridge are all embedded into the binary via Go’s embed package. There is nothing to install, nothing to bundle, nothing to ship alongside.

It works as a service too. Set GODOM_NO_BROWSER=1 GODOM_HOST=0.0.0.0 and run it on any headless machine on your LAN. Then access the UI from any browser on your network. I didn’t design this; it’s just what naturally happens when “the UI” is a tab connecting to a localhost-or-LAN HTTP server. Layer Tailscale on top of an intranet and the whole thing crosses from “demo” into “tool I actually use.” A handful of my godom apps live on home-network machines, and I open them from whatever browser I’m at (including my phone) with no SSH and no port forwarding. Tailscale is just what I happen to reach for; the same setup works with WireGuard, OpenVPN, Cloudflare Tunnel, or even a plain SSH port-forward. The point isn’t the mesh tool. It’s that godom doesn’t care how the WebSocket gets to it.

These aren’t selling points I bolted on. They’re properties of the architecture. If the browser is the screen and Go owns the state, you can’t not have multi-tab sync. You’d have to write extra code to break it.

How it actually works

Here’s the picture I keep coming back to:

Go holds the VDOM. The bridge keeps the real DOM in sync with it. Mutate a struct field; the screen reflects it.

Go holds the DOM. Not a model that gets translated into a DOM somewhere else. Not state that gets reconciled across a bridging layer. Go owns a virtual DOM tree, the same way the browser owns its real DOM. The bridge in the browser is a sync engine: it watches Go’s VDOM and keeps the real DOM matched to it. That’s the whole job.

Mechanically: when something changes (user clicks a button, a goroutine pushes new data), Go re-resolves the template into a new VDOM tree, diffs it against the previous tree, and sends only the minimal patches over a binary WebSocket (Protocol Buffers, not JSON). The bridge applies those patches. No expression evaluation in JS. No state lookup in JS. No decisions in JS. The HTML templates use g-* directives (g-text, g-click, g-bind, g-for, g-if) to bind elements to struct fields and methods, and Go resolves them on every render.

The reactive feel comes from this: I mutate a Go struct field, and the screen updates. Not “Go sends a message, the frontend rerenders.” Just: I changed a field; the screen reflects it. The same shape as setState in React, except the state lives in a backend language with real types and a real debugger and no transport layer to think about.

It’s still a little magical to me, even after building it. The conceptual gap between “server state” and “browser state” was the thing I kept working around for years. With godom that gap is gone. There’s just state, in Go, and the screen is its current view.

I wrote a separate doc on the deeper architecture if you want the full pipeline: docs/architecture.md. But the short version is the diagram above.

Some proofs

Three examples that surprised me, in increasing order of “this shouldn’t have been this easy.”

A live system monitor.

A goroutine in Go reads stats and calls Refresh(). Chart.js draws. Multi-tab sync is free.

A goroutine reads CPU, memory, disk, and load. It writes to struct fields and calls Refresh(). Chart.js does the actual drawing through godom’s plugin layer, which lets me bridge Go data to any JS chart library with a thin adapter. The whole thing is a single binary. Open it in two browser tabs and they update in lockstep.

A 3D solar system, written in Go.

3D engine and camera math in Go. Browser is just Canvas 2D.

The 3D engine, the camera, the projection math, the orbital mechanics, all of it lives in Go. The browser does nothing but drawImage and arc calls on a Canvas 2D context, driven by a list of 2D draw commands the Go side computes per frame. Mouse drag, scroll-zoom, follow-the-planet, all routed through Go’s event handlers. There is no three.js. There is no WebGL.

Claude Code, running inside an xterm.js terminal wired into godom.

A real PTY in Go. xterm.js renders. Claude Code thinks it's in a normal shell.

I didn’t write a terminal emulator; that’s xterm.js, which is also what VS Code’s integrated terminal uses. What I did is wrap it in a godom example: spawn a real shell with a PTY using creack/pty, pipe raw I/O over WebSocket, hand the bytes to xterm.js for rendering. The integration with godom is small enough to fit on one screen, and once it’s there, the rest of godom’s properties (multi-tab, session-survives-close, plugin model) come along for free. Then I typed claude into it. Claude Code doesn’t know it’s in a browser; the PTY is real, the shell is real, only the rendering is xterm.

There are more (drag-and-drop form builder, video player decoded in Go A video player that decodes frames in Go Go shells out to ffmpeg, decodes a video into JPEG frames, sends them to a browser canvas. No HTML video tag, no streami... , multi-island dashboard, breakout game Breakout, with my phone as the paddle A breakout game built on godom: laptop renders the game, phone is the paddle controller via gyroscope, both connected to... ). I’ll write about the interesting ones individually. The point of listing them here is: these aren’t toy examples. They’re the kind of thing that would normally need three libraries, two build steps, and a JS frontend.

How far I pushed it

The transport, just to name it: a WebSocket carrying binary protobuf patches in one direction and events in the other. No fancy buffering, no application-level retry logic. On a local network or a tailnet, it stays out of your way.

The first time the solar system A 3D solar system in Go, drawn with Canvas 2D I wrote a 3D engine in Go and let the browser draw 2D circles. No three.js, no WebGL, no JS math libraries. Just a list ... ran with the simulator on my laptop and the browser on a different machine on the LAN, it stopped feeling like there was a network in the loop at all. It looked like the browser was running it. That’s when I started pushing harder. The breakout game Breakout, with my phone as the paddle A breakout game built on godom: laptop renders the game, phone is the paddle controller via gyroscope, both connected to... came next, then the video player A video player that decodes frames in Go Go shells out to ffmpeg, decodes a video into JPEG frames, sends them to a browser canvas. No HTML video tag, no streami... , where Go is shipping decoded JPEG frames at 24fps. I had ideas for a video editor on top of that one; that’s the one I decided was too far for an architecture stress test. But the player was already enough to convince me the architecture can drive the kind of work that would normally hammer a browser.

Most of the heavy lifting living in Go also keeps the browser’s CPU calm. I wasn’t benchmarking. But the same workloads in pure JS would have been visibly heavier on the browser side. Go is fast, the bridge is doing nothing, and the browser’s only job is paint.

Where godom almost became a web framework

One use case I’d been planning godom for was bringing together apps I’d already built (in separate Go projects) into a single binary. Midway through, I hit a real edge.

The parts needed to work and occasionally collaborate. They’re data-heavy and memory-heavy; I didn’t want every one of them initialized at startup just because they shared one binary. godom could host them, but on its own it didn’t have answers for routing, lazy loading, or segregating one part’s state from another’s. Those are the things a real web framework gives you.

For a while I got carried away and started bending godom toward being one. That wasn’t the point.

The thing that pulled me back was a rename. I’d been calling the small composable units g-component. I changed them to g-island. The moment they were “islands” and not “components,” the Astro-style mental model clicked. Each island is a stateful unit you mount where you need it. The rest of the page is whatever you want it to be. And when two islands need to talk to each other, they still can. That fit how I actually wanted my apps to behave.

The realization was that the things godom didn’t do weren’t gaps to fill. They were boundaries to keep. Routing, lazy loading, page lifecycle, request handling: Go already has solid tools for those. godom’s job is the UI layer for state that lives in Go. If I made it a web framework, I’d be rebuilding parts of net/http and html/template for no reason.

So I leaned on Go’s standard ecosystem for the parts godom shouldn’t own. godom became something you embed into a server you already have, not a server replacement. The reference for that pattern is the examples/multi-page-v2/ example: tool packages with their own //go:embed, partials registered at startup, and islands mounted into pages served by ordinary Go HTTP handlers.

The result is a framework that stays flexible because it stops short of being one. The limits are deliberate; that’s the feature.

When godom is the wrong choice

godom is not a web framework. I’ll keep saying that.

  • If you need a real native window, dock icon, or system tray, use Wails or Tauri. godom lives in a browser tab.
  • If you’re building a multi-user web app, use a web framework. godom assumes the user and the process are co-located on a trusted network.
  • If you need offline-first PWA behavior, godom won’t help; the Go process has to be running.
  • If your audience can’t tolerate “click the binary, a tab will open,” godom isn’t for them.

But for local tools, dev utilities, dashboards, admin panels, headless-box UIs, and the kind of “I just need a screen for this Go thing” projects I keep building, it’s the option I wish I’d had years ago.

How godom actually came to exist

It started as a thought experiment. I described what I wanted to Claude, Claude helped me build it, and then I went on to structure it further: tightening the API, writing real docs, adding the plugin system, islands, drag-and-drop, surgical refresh, the Chrome extension, the multi-page mode. The core packages ended up between 95% and 100% test coverage.

I’m not going to pretend a single human wrote every line. A large amount of the implementation came from Claude, used as a real partner, not autocomplete. The architecture, the constraints, and the editorial calls are mine. The cleanup is mine. The voice in the docs is mine. The honest accounting of what was AI and what was human is in docs/AI_USAGE.md, written from day one.

There’s also an AI angle worth being explicit about, distinct from how godom got built. I’ve handcoded plenty of HTML and Go in my life; I don’t do much of it anymore. AI writes both fluently, and the productivity that comes from a real coding agent working on real code is something I didn’t want to design godom away from. So godom is meant to feel native to that workflow as much as to mine. The repo has a docs/llm-reference.md specifically for agents: a focused brief on directives, the island lifecycle, the plugin protocol, the things an LLM needs to write godom code without rederiving them from scratch. There’s a separate post How to use godom with AI agents godom ships a docs/llm-reference.md aimed at AI agents writing godom apps. Why that doc exists, what it covers, and what... on what that workflow actually looks like.

But the speed at which this got from “what if” to “feature-complete framework” was only possible because I was driving an AI capable of doing real work. There’s a separate post Building a UI framework in Go with Claude I built godom mostly with Claude as an implementation partner. Here's what that actually looked like. on what that actually looked like.

Where this goes next

godom is at a “feature-complete for what I keep building” point. Pre-1.0, but the API has stabilized enough that I’m using it for real things. Composition (partials, islands, slot children), drag-and-drop, plugins for Chart.js / Plotly / ECharts, ExecJS, surgical refresh, a Chrome extension, all in.

A few follow-up posts dig into specific examples (the terminal-with-Claude one, the solar system, the video player) and then into godom’s internals ( VDOM How godom's virtual DOM works How godom builds a virtual DOM tree in Go, diffs it against the previous tree, and ships only the patches the browser ne... , wire protocol godom's wire protocol: binary patches over WebSocket godom uses binary Protocol Buffers, not JSON, with a deliberate Browser→Go split between input value sync and explicit m... , composition How godom composes: islands, partials, and shared state godom's composition model: stateful islands, stateless partials, slot-based content, and the shared-pointer trick for st... , the JS surface When to write plain JS in godom (and when not to) godom doesn't ban JavaScript; it pushes it to the edges where it actually pays for itself. Three tiers, one rule, and th... , the thin bridge The thin bridge: keeping bridge.js dumb on purpose godom's bridge.js builds DOM, applies patches, and forwards events. It does not evaluate expressions, hold state, or mak... ). If you want to skip ahead to the practical entry point, there’s a three-stage walkthrough How to use godom Building a small godom app in three stages: a counter, two islands on one page, then a multi-page layout with a shared-s... that builds a small app end to end.

If you want to look at the code, the repo is at github.com/anupshinde/godom. The README has a counter you can run in about 30 seconds, and the examples/ folder has 25 or so things to poke at.

The browser is a screen. Your language is the app. That’s the only sentence I really want anyone to remember from this.

More on godom

If you want to go deeper, the godom set on this blog covers a few angles.

Getting started

Internals (how it works under the hood)

  • How godom's virtual DOM works How godom's virtual DOM works How godom builds a virtual DOM tree in Go, diffs it against the previous tree, and ships only the patches the browser ne... - node types, the diff, patch types, tree merging.
  • godom's wire protocol godom's wire protocol: binary patches over WebSocket godom uses binary Protocol Buffers, not JSON, with a deliberate Browser→Go split between input value sync and explicit m... - binary protobuf over WebSocket, the two-layer Browser→Go split.
  • How godom composes How godom composes: islands, partials, and shared state godom's composition model: stateful islands, stateless partials, slot-based content, and the shared-pointer trick for st... - islands, partials, slots, and shared-pointer refresh.
  • When to write plain JS in godom When to write plain JS in godom (and when not to) godom doesn't ban JavaScript; it pushes it to the edges where it actually pays for itself. Three tiers, one rule, and th... - three tiers and the rule that picks between them.
  • The thin bridge The thin bridge: keeping bridge.js dumb on purpose godom's bridge.js builds DOM, applies patches, and forwards events. It does not evaluate expressions, hold state, or mak... - what bridge.js does and refuses to do.

How godom got built

  • Building a UI framework in Go with Claude Building a UI framework in Go with Claude I built godom mostly with Claude as an implementation partner. Here's what that actually looked like. - the honest AI-build journal: what AI wrote, what I redirected, what mattered most.

Things built with godom

  • Claude Code in a browser terminal I put Claude Code in a browser terminal I built in Go I built a browser-based terminal in Go using godom and xterm.js. Then I ran Claude Code inside it. It just worked. - a real PTY-backed terminal rendered with xterm.js, running Claude Code inside it.
  • A 3D solar system engine in Go A 3D solar system in Go, drawn with Canvas 2D I wrote a 3D engine in Go and let the browser draw 2D circles. No three.js, no WebGL, no JS math libraries. Just a list ... - the planets and a few moons, Go does the math, the browser paints pixels.
  • A video player that decodes frames in Go A video player that decodes frames in Go Go shells out to ffmpeg, decodes a video into JPEG frames, sends them to a browser canvas. No HTML video tag, no streami... - ffmpeg-decoded frames over WebSocket, rendered onto a canvas.
  • Breakout with a phone controller Breakout, with my phone as the paddle A breakout game built on godom: laptop renders the game, phone is the paddle controller via gyroscope, both connected to... - a desktop Breakout game with phone gyro as the paddle.