picks · 24 Oct 2026 · 8 min read
go-app — the whole PWA in Go, front-end included
Ten years of one stubborn idea: components, DOM diffing, service worker, server-side rendering, all written in Go and shipped as WebAssembly. We read the engine. It's gorgeous, and it has sharp edges.
Every Go programmer has had the thought: what if I could write the front-end in Go too? Most of us had it, sighed, and opened a package.json. Maxence Charriere had it in 2016 and never stopped. Ten years and eleven major versions later, go-app is the most complete answer the Go ecosystem has: components, a DOM differ, routing, state management, a service worker, push notifications, install prompts and server-side rendering, all in Go, compiled to WebAssembly for the browser and to a normal binary for the server.
Totally biased disclaimer, as always on Picks: we love this project. It’s on awesome-go, it has nearly 9,000 stars, and its own website go-app.dev is built with it. We read the engine at 81a3835 (v11.1.x). Links are permalinks into that commit.
At a glance
| Language | Go 1.26, compiled to GOOS=js GOARCH=wasm for the client |
| Size | ~71k lines in pkg/, of which ~60k are generated HTML bindings |
| Hand-written core | node.go 814 lines, engine.go 439, state.go 487, context.go 326 |
| Tests | 326 Test*, 26 benchmarks, a test engine and Node-run service-worker tests |
| Dependencies | google/uuid, x/net, testify |
| Age | Since October 2016, v11.1.3 on 22 Oct 2026 |
| License | MIT |
A component is a struct with a Render method
Here’s the whole mental model: you embed app.Compo, you implement Render() app.UI, and you write HTML with a fluent, typed builder.
type hello struct {
app.Compo
name string
}
func (h *hello) Render() app.UI {
return app.Div().Body(
app.H1().Text("Hello, " + h.name),
app.Input().
Value(h.name).
OnChange(h.ValueTo(&h.name)),
)
}
No JSX, no template language, no codegen step in your project. Compo exists for a clever reason: the Composer interface has unexported methods (setRef, depth, parent, root…) so the only way to satisfy it is to embed app.Compo. The type system enforces the framework contract for you.
Lifecycle hooks are the most idiomatic Go you could ask for: optional one-method interfaces discovered by type assertion. Implement OnMount(ctx) and you get mount events. Implement OnNav(ctx) and you hear about navigation. OnAppUpdate, OnAppInstallChange, OnResize and OnPreRender (server-side only) work the same way. There’s no registration, no base-class soup, just interfaces.
60,000 lines of HTML, none of them hand-written
html_gen.go is 59,544 lines long, and starts with the sacred words // Code generated by go generate; DO NOT EDIT. A small generator, gen/html.go, holds a table of tags, attributes and events, and emits one interface and one struct per HTML element:
func (e *htmlDiv) Class(v ...string) HTMLDiv { e.setAttr("class", strings.Join(v, " ")); return e }
func (e *htmlDiv) OnClick(h EventHandler, options ...EventOption) HTMLDiv { return e.On("click", h, options...) }
Because every method returns the element’s own interface (HTMLDiv, HTMLInput…), the compiler only offers attributes that are valid for that tag. app.A().Href(...) compiles, app.Div().Href(...) doesn’t. Your editor’s autocomplete is the HTML spec. Conditionals are lazy: app.If(cond, func() app.UI {...}) takes closures, so branches that aren’t taken are never built.
One goroutine to rule the DOM
The component tree has no locks. Instead, everything that touches the UI runs on one goroutine, the engine’s dispatcher. It’s a select over a 4096-slot channel and a ticker that switches between an active frame rate and an idle one:
case <-frames.C:
e.processFrame()
frames.Reset(iddleFrameDuration)
currentFrameDuration = iddleFrameDuration
The first dispatch of a burst bumps the ticker to the active rate. The next tick renders every dirty component once, runs the deferred callbacks, and the ticker goes back to sleep for an hour. Ten state changes in a click handler mean one render. The whole scheduler is about thirty lines, and it’s beautiful.
How does a component become dirty? Every DOM event handler is wrapped in ctx.Dispatch, which marks the source component and all its ancestors for update:
// Dispatch prompts the execution of a function on the UI goroutine,
// flagging the enclosing component for an update.
func (ctx Context) Dispatch(v func(Context)) {
ctx.dispatch(func() {
if !ctx.sourceElement.Mounted() {
return
}
for c, ok := component(ctx.sourceElement); ok; c, ok = component(c.parent()) {
ctx.addComponentUpdate(c, 1)
}
Updates are queued in buckets by depth ([]map[Composer]int), so parents always render before their children. Want to skip a render? ctx.PreventUpdate() adds -1 to the same counter. Background work goes through ctx.Async (a tracked goroutine) and comes back with ctx.Dispatch. It’s the event-loop discipline every UI toolkit needs, expressed with plain channels.
There’s also a real state system on top: ctx.SetState("user", u).Persist().Broadcast() stores a value, writes it to localStorage, and shares it with your other open tabs through a BroadcastChannel. ctx.ObserveState("user", &c.user).While(...).OnChange(...) subscribes a component, and the subscription is pruned when it dismounts.
The same code runs on the server, and that’s the SSR trick
This is our favourite design decision. go-app wraps syscall/js behind its own Value interface. In the browser, js_wasm.go implements it with the real thing. Everywhere else, js_nowasm.go (//go:build !wasm) provides an inert stub: every Get or Call returns an empty value, and DOM operations do nothing.
So the entire engine, with your components, navigation and the dispatcher, compiles and runs on a normal server. app.Handler uses exactly that to pre-render pages:
engine := newEngine(ctx, &routes, h.Resources.Resolve, &page, actionHandlers)
engine.Navigate(page.URL(), false)
engine.ConsumeAll()
// ...
err := engine.Encode(&b, h.HTML().Lang(page.Lang()).privateBody(Head().Body(...
No headless browser and no separate rendering path: the server builds a real engine per request, navigates, drains its queues and serialises the tree to HTML. ConsumeAll even waits for ctx.Async work, so OnPreRender can fetch data before the HTML goes out. Crawlers (detected by user agent) get that HTML and skip the WebAssembly entirely.
The whole PWA is one http.Handler
http.Handle("/", &app.Handler{
Name: "Hello",
Description: "An Hello World! example",
Version: "v1.0.0",
})
That struct serves the HTML pages, app.js, wasm_exec.js, the web manifest, the stylesheet and the service worker. The worker pre-caches the app shell and the wasm binary, and tells the Go side when a new version is ready, via OnAppUpdate. Push notifications are wrapped in a NotificationService with VAPID subscriptions. There is even GenerateStaticWebsite, which spins up an httptest.Server on your own handler and crawls it to produce a static site you can host on GitHub Pages.
A detail we loved in the history: a 2025 commit titled “Avoid using text/template as it disables DCE and increases binary size”. Generated JS is filled in with a strings.NewReplacer instead. That’s what caring about WebAssembly size looks like.
Testing gets the same care. app.NewTestEngine() lets you mount components and drain the dispatcher in a plain go test, with no browser involved. The generated service worker is executed in a Node vm sandbox with fake caches from Go tests. Browser tests go through wasmbrowsertest.
Where we’d send a PR
Partial, not blind. Here’s what bit us, or would bite you, while reading.
Stale closures in event handlers. When the differ compares two handlers, it compares the code pointer of the Go func:
func (h eventHandler) Equal(v eventHandler) bool {
return h.event == v.event &&
h.scope == v.scope &&
h.passive == v.passive &&
reflect.ValueOf(h.goHandler).Pointer() == reflect.ValueOf(v.goHandler).Pointer()
}
Two closures from the same literal share that pointer even if they captured different variables. So in a list where row i has OnClick(func(...) { c.remove(i) }), a re-render after reordering can keep the old closure, with the old i. The framework knows: app.EventScope(...) exists precisely to disambiguate handlers “as UI elements are reordered”. It’s a footgun with a documented workaround, but a footgun.
No keyed lists. Children are diffed by index:
sharedLen := min(len(children), len(newChildren))
for i := 0; i < min(len(children), len(newChildren)); i++ {
Insert at the top of a 500-row list and every row gets updated. Components keep their identity by position, not by key, which compounds the closure issue above. A Key() option, React-style, would be the single most impactful addition.
Generous re-rendering, with reflection. Because Dispatch marks every ancestor dirty, a click deep in the tree re-renders up to the root unless you call PreventUpdate. Child components are updated by walking their exported fields with reflect and reflect.DeepEqual. Fields holding funcs never compare equal, so those children re-render every time their parent does. It’s fine for typical apps, but it’s where performance goes when it goes.
The Version default. Leave Handler.Version empty and it becomes the SHA-1 of time.Now() at startup (http.go#L171-L177). With two instances, or a restart, every client sees an “update” and re-downloads the wasm. The doc comment says “Must be set in live environments”. We’d rather see a loud log line, or a version derived from the wasm’s hash.
Offline deep links. The service worker is cache-first on exact requests and pre-caches /. As far as we can tell, cold-opening /about while offline misses the cache, because there’s no navigation fallback to the app shell.
“Encrypted” persisted state. PersistWithEncryption() uses AES-GCM keyed on the DeviceID, which is itself stored in the same localStorage (context.go#L80-L91). It hides values from casual inspection, nothing more. The name promises more than that.
The wasm size. A Go wasm binary starts in the megabytes. go-app does what it can (a loader with a progress bar, a WasmContentLength hint for compressed responses, the text/template purge), but compression and CDN are up to you. Budget for it, or try TinyGo.
Why we love it
- One language end to end: components, routing, state, service worker, SSR.
- A typed HTML builder generated from the spec: autocomplete is validation.
- A single-goroutine UI dispatcher with frame coalescing, in about thirty lines.
- SSR by running the very same engine on the server with an inert JS layer.
- Ten years of maintenance by one person, still shipping in 2026.
Where it could be better
- Handler equality by code pointer: stale closures unless you use
EventScope. - Index-based child diffing, no keys.
- Reflection-heavy updates and ancestor-wide re-renders.
- Footgun defaults: random
Version, no offline deep links.
Try it
mkdir hello && cd hello && go mod init hello
go get -u github.com/maxence-charriere/go-app/v11/pkg/app
# write main.go: a component, app.Route("/", ...), app.RunWhenOnBrowser(),
# and an http.Handle("/", &app.Handler{...}) for the server side
GOARCH=wasm GOOS=js go build -o web/app.wasm
go build && ./hello
The getting started guide walks through the full main.go. The trick that makes it click: the same main.go is built twice, once for wasm (the browser takes RunWhenOnBrowser) and once natively (the server serves the Handler).
If you’ve ever wanted to delete a node_modules folder with prejudice, go star maxence-charriere/go-app. And if you build something with it, write the Key() PR. We’ll cheer.