Building
Go in components
Status: experimental. The API and the build can change in any release. Enable it with
"experimental": { "goScript": true }intern.json. This page is about Go compiled to WebAssembly; for Go running on the server, which is supported, see Live components.
A Svelte component can have its logic in Go instead of TypeScript. The markup stays Svelte. The Go code runs in the browser as WebAssembly, with Go versions of Svelte's runes.
<script lang="go">
import "github.com/Unfield/Tern/goscript"
func Setup(c *goscript.Component) {
count := goscript.State(c, "count", 0)
goscript.Derived(c, "doubled", func() int { return count.Get() * 2 })
c.Func("increment", func() { count.Set(count.Get() + 1) })
}
</script>
<button onclick={increment}>{count} × 2 = {doubled}</button>
See examples/goscript for a working app.
#The API
| Go | Svelte equivalent | In the markup |
|---|---|---|
goscript.State(c, "name", initial) |
let name = $state(initial) |
{name} |
goscript.Derived(c, "name", func() T) |
let name = $derived(...) |
{name} |
goscript.Effect(c, func()) |
$effect(...) |
|
c.Func("name", fn) |
a function | onclick={name} |
c.Props(&props) |
$props() |
<Counter start={10} /> |
- Signals:
Getreads a signal and subscribes the runningDerivedorEffectto it.Peekreads without subscribing.SetandUpdatewrite. - Batching: changes made in one
Funccall are applied together, so a derived value recomputes once. - Arguments:
Funcarguments are decoded from the JavaScript values passed to them. DOM events arrive as agoscript.EventwithValue,CheckedandKey. AFunc's return value goes back to JavaScript.
#How it works
- Scanning. When Vite starts (
tern dev) or builds (tern build), Tern finds every.sveltefile with a<script lang="go">. It writes each script as its own Go package under.tern/goscript/, and amainpackage that registers them all.//linedirectives make Go's compile errors point at the.sveltefile and line. - Initial state. That program runs once natively (
go run), mounting every component with empty props and recording its state. The result is the initial state, so server rendering and the first paint show real values instead of empty ones. - WebAssembly. The same program is built for
GOOS=js GOARCH=wasminto one module for the whole app. It uses the Go toolchain's matchingwasm_exec.js. - Bindings. A Svelte preprocessor replaces the Go script with a few lines of TypeScript:
- a
useGo()call - one
$derivedper state name, reading the values Go publishes - one function per
Func
- a
- In the browser. The module loads once per page. Each component instance mounts its own Go instance, which publishes values into Svelte state through a small bridge. Calls and events go the other way as JSON.
#What it costs
Measured in the example app, in Chrome on an Apple M4:
| Go (WebAssembly) | TypeScript | |
|---|---|---|
| Counting primes to 5 million (sieve), median of 15 warm runs | 23.2 ms | 15.6 ms |
| A click through the bridge: Go call, state change and Svelte update | 0.04 ms | |
| Download | 4.5 MB, 1.26 MB gzipped | part of the normal bundle |
| Build time added | about 1.5 s (go run plus go build) |
Speed: WebAssembly compiled by the standard Go toolchain is not faster than JavaScript for this kind of code. V8's JIT optimizes a simple loop over a Uint8Array very well, while Go's WebAssembly output has bounds checks, a larger runtime and no SIMD by default. The bridge is fast enough to be invisible in UI code.
Size: the module is big because it carries the Go runtime, garbage collector, reflect and encoding/json. It is paid once per page, but it is still about ten times a typical Svelte page's JavaScript.
#Live components
Add live to the script tag and the same Go runs on the server instead, connected to the page over a WebSocket. Live components are supported and need no flag; see Live components.
#Limitations
- Props: they are read when a component mounts. Changing them afterwards remounts the Go instance.
- Initial state: it is computed with empty props. Values that depend on props appear once the module loads.
- Shared module: all Go components share one module. A component with a Go script can't also have a TypeScript script.
- Tooling:
tern checkchecks the markup, but WebAssembly components' values are typedany. Rungo veton.tern/goscriptto check the Go. - SSR timing: server rendering shows the build-time initial state, not state computed for each request.
#Where this could go
The experiment shows what Go in components is good for, and what it isn't:
- Not for speed. For typical UI logic, TypeScript in Svelte is faster to load and to run. A future switch to TinyGo could shrink the module to a few hundred kilobytes, but would not make tight loops faster than V8.
- For sharing Go code. The real value is that the same Go runs on the server and in the browser. Validation rules, pricing, permissions and parsers can be written once, with Go's types and libraries, without a TypeScript copy drifting out of sync. The build-time initial state is a first taste of that.
- Server-run components became live components, the supported way to write component logic in Go.
See ADR 0009 for the decision.