01 Hello World — Technical Details

This page covers the internals of how the simplest lofigui app works: the request lifecycle, polling mechanism, graceful shutdown, and the WASM service worker bootstrap.


Service worker WASM bootstrap

When the WASM demo runs in the browser, a service worker intercepts HTTP requests and routes them to Go's net/http handlers running inside the WASM binary. The bootstrap sequence has several async phases:

Swimlane diagram: service worker WASM bootstrap flow showing page, SW, WASM, and static server interactions
Key insight: handlerPromise. The go-wasm-http-server library registers a fetch event listener synchronously, but the WASM binary loads asynchronously. All intercepted requests call event.respondWith(handlerPromise.then(handler)) — they are queued until the Go code calls wasmhttp.Serve(mux), which resolves the promise. There is no timeout: if the WASM fails to compile, requests hang indefinitely.
skipWaiting + clients.claim. By default, a new service worker waits for all tabs using the old worker to close. skipWaiting() activates immediately; clients.claim() takes control of the bootstrap page so that fetch() calls from JavaScript go through the SW rather than directly to the static server.
Stale SW cleanup. The bootstrap page calls getRegistrations() and unregisters all existing service workers before registering the new one. Without this, a stale SW from a previous directory layout can intercept requests and serve incorrect responses.

Request lifecycle

Sequence diagram: browser polls server while model runs, server exits when model completes

Handle — single-endpoint serving

app.Handle(model) combines start and display into one endpoint. It uses the buffer as state:

Buffer Action running Behaviour
Empty No Start model goroutine, render with Refresh header
Any Yes Render current buffer with Refresh header (polling)
Non-empty No Render final output, no Refresh header (done)
Why the buffer? — After the model completes, the browser has one pending refresh from the last response's Refresh header. Without the buffer check, that stale refresh would restart the model. The non-empty buffer signals "completed" without needing a third state flag.

Auto flush and shutdown

When the model goroutine returns normally, Handle calls flush() automatically:

  1. EndAction() — stops polling (no more Refresh headers)
  2. Grace period (2s) — the browser's pending refresh arrives, gets the final page without a Refresh header
  3. signalDone() — triggers http.Server.Shutdown()

The server returns nil from app.ListenAndServe on graceful shutdown (exit code 0). Panics and bind errors return non-nil (exit code 1).

Flush is implicit in Handle, explicit elsewhere — models using HandleRoot/HandleDisplay (later examples) call EndAction() directly. The server stays alive for restart. Flush() is available as a public method for models that want to trigger shutdown explicitly.

Lazy defaults

For the simplest case, NewApp() provides sensible defaults that later examples override:

Default Value Override
Controller Built-in Bulma template (created lazily on first request) app.SetController(ctrl)
Refresh time 1 second app.SetRefreshTime(n)
Display URL /display app.SetDisplayURL(url)
Favicon Auto-registered on DefaultServeMux Register your own /favicon.ico handler first

HandleRoot / HandleDisplay

For apps that need restart support or a long-lived server, use separate endpoints:

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    app.HandleRoot(w, r, model, true)
})
http.HandleFunc("/display", func(w http.ResponseWriter, r *http.Request) {
    app.HandleDisplay(w, r)
})

HandleRoot resets the buffer, starts the model, and redirects to /display. HandleDisplay renders the current state. The model calls EndAction() when done. The server stays alive — visiting / again restarts the model.