How to Call JavaScript from Go Wasm

Web
Call JavaScript from Go WebAssembly using the syscall/js package to access global objects and invoke methods.

The bridge between two worlds

You write a Go program. It compiles to WebAssembly. You drop it into a browser. The browser gives you access to the DOM, the network stack, and the console. Go does not know any of that. It only knows its own standard library and its own memory layout. You need a way to hand control back to the browser when you need to read a file, make a network request, or update the screen. That handoff happens through JavaScript.

Go WebAssembly does not run in a vacuum. It runs inside a sandbox provided by the browser. The sandbox exposes a fixed set of host functions. The syscall/js package is the official Go interface to those host functions. It does not translate Go code into JavaScript. It provides a controlled boundary where Go can ask the browser to do something and receive a result.

How the bridge actually works

Think of WebAssembly as a sealed room. Go runs inside it. JavaScript runs outside it. The two languages share no memory layout and no type system. You cannot pass a Go slice directly to a browser API. You need a translator.

The syscall/js package wraps JavaScript values in a Go type called js.Value. When you call a method on that wrapper, the package marshals the arguments, crosses the boundary, executes the JavaScript, and marshals the result back. It is a bridge, not a merger. Every crossing has a cost. Arguments are copied across the boundary. Large data transfers are slow. Keep payloads small and cross the boundary only when necessary.

The boundary is a wall, not a door. Cross it deliberately.

Your first cross-language call

Here is the smallest possible cross-language call: grab the global object, find a method, and execute it.

package main

import (
	"syscall/js"
)

func main() {
	// Access the global JavaScript environment object
	global := js.Global()
	// Retrieve the alert function from the global scope
	alert := global.Get("alert")
	// Execute the function with a Go string that gets converted to JS
	alert.Call("Hello from Go")
}

Compile this with GOOS=js GOARCH=wasm go build -o main.wasm. The compiler replaces the standard Go runtime with a WebAssembly-specific version. It links the syscall/js package against the browser's host environment. When you run the resulting file in a browser, js.Global() returns a handle to the window object. Get("alert") looks up the function by name. Call invokes it. The string argument is copied into JavaScript memory, the browser shows the dialog, and control returns to Go.

What happens under the hood

The compilation step does more than change the target architecture. It strips away operating system syscalls and replaces them with WebAssembly host calls. The syscall/js package becomes a thin layer over the WebAssembly import/export mechanism.

When you call .Call(), Go pauses. The WebAssembly runtime yields control to the browser's JavaScript engine. The JS engine runs the function. The result is marshaled back into a js.Value. If the JavaScript function returns undefined, the Go wrapper becomes an undefined value. If it returns a number, the wrapper holds a float64. If it returns an object, the wrapper holds a reference to that object in JavaScript memory.

Go does not garbage collect JavaScript objects. The browser does. If you hold a js.Value that references a large DOM node, the browser keeps that node alive even if you never use it again. Drop the js.Value when you are done. Let the browser reclaim the memory.

WebAssembly yields control. JavaScript takes it back. Respect the handoff.

A realistic browser integration

Browsers run on an event loop. Network calls and DOM updates happen asynchronously. Go handles this by wrapping JavaScript callbacks in js.FuncOf and bridging them to Go channels.

Here is how you call fetch and wait for the response in Go:

package main

import (
	"fmt"
	"syscall/js"
)

func main() {
	// Create a channel to receive the result from the async JavaScript call
	result := make(chan string, 1)

	// Wrap a Go function so JavaScript can call it when fetch completes
	callback := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
		// The first argument from the Promise resolve is the Response object
		response := args[0]
		// Extract the status code and send it back to the Go channel
		result <- fmt.Sprintf("Status: %d", response.Get("status").Int())
		return nil
	})

	// Trigger the browser fetch API and attach the Go callback
	fetch := js.Global().Get("fetch")
	fetch.Call("https://httpbin.org/get").Get("then").Call(callback)

	// Block until the JavaScript callback writes to the channel
	fmt.Println(<-result)
}

The js.FuncOf call converts a Go function into a JavaScript callable. The browser stores that callable and invokes it when the promise resolves. The callback receives this and args as js.Value slices. You extract what you need, send it over a channel, and return nil to JavaScript. The main goroutine blocks on the channel until the browser finishes the request.

Async JavaScript meets synchronous Go. Channels are the handshake.

Where things break

The syscall/js package does not protect you from JavaScript semantics. It exposes them directly.

Calling .Get() on a property that does not exist returns an undefined js.Value. Calling .Call() on an undefined value panics at runtime with js: undefined value. The compiler will not catch this. You must check the value first or handle the panic.

// Check if the value is defined before calling
if !global.Get("myCustomAPI").IsUndefined() {
    global.Get("myCustomAPI").Call("doSomething")
}

Another common failure is scope leakage. js.FuncOf returns a js.Func that holds a reference to your Go closure. If that js.Func goes out of scope in Go, the JavaScript engine drops the reference. The callback becomes a no-op. The compiler does not warn you. You get silent failures where your network request finishes but your channel never receives. Keep the js.Func alive for as long as the browser might call it. Store it in a package-level variable or a struct field if the callback outlives the function that created it.

Passing unsupported types across the boundary also fails fast. syscall/js only supports basic types, slices, maps, and js.Value. Try to pass a *struct and the compiler rejects it with cannot use ptr (type *MyStruct) as syscall/js.Value in argument. Flatten your data into slices or maps before crossing the boundary.

Undefined values panic. Scope leaks silence your callbacks. Test the boundary.

When to reach for JavaScript interop

Use direct JavaScript calls when you need browser APIs that Go lacks, like DOM manipulation, file readers, or the Web Audio API. Use Go channels wrapped in js.FuncOf when you need to bridge asynchronous JavaScript promises to synchronous Go code. Use plain Go standard library functions when the task is computation, data parsing, or network I/O that does not require browser privileges. Use a JavaScript framework bridge when you are embedding Go inside a React or Vue app and need structured component communication.

Go handles logic. JavaScript handles the browser. Keep them in their lanes.

Where to go next