skip to content

How do you expose a Go function to JavaScript with syscall/js, and why must main not return?

level: middleimportance: should knowfreq 30%

answer

  1. wrap the Go func first
  2. hand the wrapper to a JS object
  3. the program exits with main
  4. select {} keeps it callable
  5. release wrappers you create per call

basics

~20 s

Wrap the Go function with js.FuncOf and attach it to a JavaScript object, usually via js.Global().Set. The program exits when main returns, and once it has exited the wrapper can no longer be called, so a callback-serving module blocks forever.

solid answer

~40 s

You wrap the Go function with `js.FuncOf`, whose argument has the fixed shape `func(this js.Value, args []js.Value) any`, and register the resulting `js.Func` somewhere JavaScript can see it — typically `js.Global().Set("validateDoc", fn)`. Arguments arrive as opaque `js.Value` handles: pull text out with `args[0].String()`, and copy bulk bytes out of a `Uint8Array` with `js.CopyBytesToGo`. Whatever you return is converted back into a JavaScript value, so a `map[string]any` becomes an object. Two lifetime rules matter. Each `js.Func` is pinned in a runtime table until you call `Release()`, so a callback you create per request and never release is a leak. And `go.run()` resolves when `main` returns, after which calls into the module fail because the Go program has already exited — so a module that exists only to serve callbacks ends `main` with `select {}`.

code

go · 13 lines
go
func validate(this js.Value, args []js.Value) any {
	doc := args[0].String() // copied and transcoded from a UTF-16 JS string
	return map[string]any{
		"ok":    len(doc) > 0,
		"runes": utf8.RuneCountInString(doc),
	}
}

func main() {
	fn := js.FuncOf(validate)
	js.Global().Set("validateDoc", fn)
	select {} // keep the program alive so the wrapper stays callable
}

go deeper

for a junior

Recall the two steps: wrap with js.FuncOf, then publish it with js.Global().Set. Remember that the program ends when main ends, so the module blocks instead.

for a middle

Explain the fixed callback signature, how arguments arrive as handles, what conversions happen on the way in and out, and why Release exists.

for a senior

Show boundary design judgment: batch data instead of crossing per field, recover panics inside callbacks so one bad input cannot kill the module, and give the module a deliberate shutdown path.

for a principal

Decide what the exported surface looks like before anyone builds on it. The names on the global object become an API another team depends on, and copying costs shape whether the boundary is one call or a thousand.

## The model: handles, not shared objects On the `GOOS=js` target, the `syscall/js` package gives Go a view of the JavaScript world. The central type is `js.Value`: an opaque handle to a value that lives in the JavaScript heap. Go never holds a JavaScript object directly; it holds a reference, and every read or write crosses the boundary. `js.Global()` returns the handle for the global object, and from there `Get`, `Set`, `Call`, `Invoke`, `Index` and `New` do property access, method calls, function calls and construction. ## Exporting a Go function JavaScript cannot call a Go function directly — it can only call a JavaScript function. `js.FuncOf` builds one: func FuncOf(fn func(this js.Value, args []js.Value) any) js.Func The signature is fixed. `this` is the JavaScript receiver, `args` are the call arguments as handles, and the `any` you return is converted back to a JavaScript value. The resulting `js.Func` is itself a `js.Value`, so you publish it by assigning it somewhere reachable: `js.Global().Set("validateDoc", fn)` puts it on `window`. Attaching it to your own namespace object is tidier than scattering names on the global. ## Marshalling: everything is a copy Nothing is shared across the boundary. `args[0].String()` copies the JavaScript string into a Go string, transcoding from JavaScript's UTF-16 representation into Go's UTF-8 bytes; an unpaired surrogate on the JavaScript side becomes the replacement character U+FFFD in Go. Going the other way, returning a Go string allocates a JavaScript string. For bulk data you use the byte helpers: `js.CopyBytesToGo(dst []byte, src js.Value)` reads a `Uint8Array` into a Go slice, and `js.CopyBytesToJS(dst js.Value, src []byte)` writes the other way; both return the number of bytes copied, which is the minimum of the two lengths. The consequence for design is simple: a chatty API that crosses the boundary per character or per field will be dominated by copying, so batch — hand the whole document over once and return one result object. Return conversion follows `js.ValueOf` rules: `nil`, booleans, numbers, strings, `[]any` and `map[string]any` all have direct JavaScript equivalents. A Go struct does not — build a map, or marshal to JSON and hand over the string. ## Lifetime rule one: Release A `js.Func` is registered in a table inside the Go runtime so that the JavaScript side can find its way back to your Go closure. That table entry keeps the closure, and anything it captures, alive forever until you call `Release()`. A wrapper created once at start-up and left registered is fine and normal. A wrapper created per call — a promise executor, a one-shot event handler — must be released once it can no longer fire, or the module leaks steadily. ## Lifetime rule two: main must not return `go.run(instance)` in the glue runs `main` and returns a promise that resolves when `main` returns. When it does, the Go program has *exited*: the runtime is finished, and a later call into a registered wrapper fails with an error saying the Go program has already exited. This surprises people, because in a browser the natural mental model is “load a library” rather than “run a program” — but it really is a program with a `main`. So a module whose whole purpose is to serve callbacks registers them and then blocks the main goroutine forever. `select {}` is the idiomatic form; receiving from a channel nobody sends on is equivalent. Blocking `main` this way does not freeze the page: when every goroutine is parked, the runtime returns control to the JavaScript event loop, and a later call into a registered wrapper wakes Go up again. If you *do* want a shutdown path, block on a channel and close it from an exported `stop` function. ## Failure handling An unrecovered panic inside a callback is fatal to the whole module, not just to that call — the Go program dies and every other registered function stops working. Wrap the body, recover, and return an error value the JavaScript caller can inspect. That is also the honest boundary design: JavaScript has no notion of Go's second return value, so encode failure into the object you return.

  • When is calling Release on a js.Func mandatory rather than optional?
    Whenever you create wrappers repeatedly. Each one is pinned in a runtime table together with everything its closure captures, so per-request handlers, promise executors and one-shot event listeners leak until released. A handful registered once at start-up and never released is fine, because their lifetime is the program's lifetime anyway.
  • How does a Go string reach JavaScript, and what does that cost?
    It is copied and transcoded: Go strings are UTF-8 bytes, JavaScript strings are UTF-16, so every crossing allocates on the far side. Nothing is shared. That makes per-field or per-character chatter across the boundary expensive, and argues for handing over one payload and returning one result object.
  • What happens if a panic escapes a syscall/js callback?
    The whole Go program dies, not just that call, so every other registered function stops working too. Recover inside the callback and return an error-shaped value — JavaScript cannot see Go's second return value, so failure has to be encoded in the object you hand back.

saying these in an interview costs you the question

  • Registers callbacks and lets main return
  • Creates a js.Func per call and never releases it
  • Assumes Go and JavaScript share the string in memory
  • Returns a Go struct and expects a JavaScript object
  • Lets panics escape a callback into the host