Browser App (WebAssembly)
The same App can run inside the browser, compiled to WebAssembly, with no Go process behind it. Only the executor changes: pages, components and state work exactly as they do on the web.
//go:build js && wasm
package main
import (
"github.com/voilelab/toolgui/toolgui/tgcomp"
"github.com/voilelab/toolgui/toolgui/tgframe"
"github.com/voilelab/toolgui/toolgui/tgwasm"
)
func main() {
app := tgframe.NewApp()
app.AddPage("index", "Index", func(p *tgframe.Params) error {
tgcomp.Text(p.Main, "Hello world")
return nil
})
// A static host cannot route paths, so pages live in the hash.
app.SetHashPageNameMode(true)
tgwasm.NewExecutor(app).Run()
}
Pages are then linked as /#/{name} — #/index here, not #index. See
Page name in the URL.
Run installs the bridge the page talks to and blocks forever, keeping the
wasm instance alive to answer it.
tgwasm is behind //go:build js && wasm, so an app that also ships a server
build splits its entry points:
page.go the pages, no build tag
main_server.go //go:build !(js && wasm) tgexec.NewWebExecutor(app).StartService("127.0.0.1:3000")
main_wasm.go //go:build js && wasm tgwasm.NewExecutor(app).Run()
Building it
toolgui-wasm compiles the app and writes the site around it:
go get -tool github.com/voilelab/toolgui/cmd/toolgui-wasm
go tool toolgui-wasm build -o dist ./cmd/myapp # the site, into dist/
go tool toolgui-wasm serve ./cmd/myapp # the same, at :3000
-ldflags goes through to go build, e.g. -ldflags="-X main.version=1.0".
-s -w saves little here: wasm keeps most of its size in code, not symbols.
serve is there so the browser gets the binary as application/wasm and can
compile it while it downloads; deploying needs no server at all. Neither
command deletes anything it did not write, so -o can point at a web root.
The example app
toolgui/tgwasm/example/hello
is a runnable version, with three pages, a sidebar textbox, a button and a file
the page function reads without it leaving the tab:
task build_wasm_hello # static site, in toolgui/tgwasm/example/hello/build
task run_wasm_hello # the same, served at http://localhost:3000
The component demo runs in the browser too — task run_wasm_demo, and it is
published beside this book.
What a build produces
build/
├── index.html the page
├── static/ the frontend bundle and the worker
├── wasm_exec.js the Go runtime shim
└── app.wasm your app
Four static files. Any file server serves them; there is no backend. It does
have to be an https one, though, or localhost — see the hosting notes.
The CLI does nothing a shell cannot:
GOOS=js GOARCH=wasm go build -o dist/app.wasm ./your/package
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/
# plus the frontend, which the CLI carries as an embedded copy
wasm_exec.js is copied out of the toolchain that built the binary rather than
vendored: the two have to match. That is also why the CLI shells out to go build instead of asking you to run it — one toolchain, both halves.
Where it runs
The Go program runs in a dedicated Web Worker, not on the page's thread, and it has to: the synchronous file handles the file store keeps uploads in exist in a worker and nowhere else. It also never touches the DOM — it sends the same packs the update websocket carries, and the React components on the main thread render them — so a page function that takes a while leaves the UI responsive.
What the browser takes away
- One session per tab, created on load. A reload starts from an empty state: there is no server to keep it.
- No filesystem to open by path, and no listening socket.
net/httprequests becomefetch, so CORS applies to whatever your page function calls. GOMAXPROCSis 1. Goroutines interleave, nothing runs in parallel.- No timezone database unless the app imports
time/tzdata. - Uploaded files are kept in the origin private file system rather than the tab's memory, and go with the session. Storage there is per origin and bounded, so an upload can fail for want of room. Getting there is a stream: the worker copies the picked file straight into storage and hands Go a handle on it, so an upload is bounded by the origin's room for it and not by the tab's memory.
- The binary is public, like any other static asset. No secrets in it.
SetManifestandSetAssetsareWebExecutorsettings, so the browser build does without them. A static site can carry amanifest.jsonand its own files next toindex.htmlinstead.
Hosting notes
- Serve it over
https. Uploads go to the origin private file system, and that belongs to a secure context, so on plainhttpfrom anything butlocalhostthere is nowhere to put one and every upload fails. It says so rather than falling back to the tab's memory, which would put the files back where this build stopped keeping them without anyone noticing. GitHub Pages, Netlify and the like arehttpsalready. - Serve
.wasmasapplication/wasm, so the browser can compile it while it downloads. - Compress it. With Go 1.27 the example app is 8.2 MB, 2.2 MB gzipped; the component demo is 10.2 MB, 2.7 MB gzipped. It is cached after the first load, and the page shows how much of it has arrived until then.
- GitHub Pages gzips it for any browser that asks, which is all of them, so a
visitor downloads the gzipped size. DevTools' Network panel shows both: the
transferred size is what went over the wire, the resource size is the
binary after decompression.
Content-Encoding: gzipon the response is how to tell a host does it. A host that does not can serve a precompressedapp.wasm.gzor.brinstead, if it can set the header. - Keep
SetHashPageNameMode(true)unless the host can rewrite unknown paths toindex.html. - The build uses relative asset URLs, so it works at a site root and under a
project path like
/toolgui/without rebuilding.
Why not TinyGo
TinyGo makes much smaller binaries, but it cannot build a ToolGUI app. Checked with TinyGo 0.40.1:
- It supports Go up to 1.25, and this module needs 1.27.
- Past that, its standard library has no
encoding/json/v2and nouuid, whichtgjson,tgframeandtgutilimport.
Worth another look when TinyGo catches up with the Go release.