WebAssembly and WASI

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27. WebAssembly (Wasm) is a portable, sandboxed binary instruction format. A Wasm module has no operating system, no threads of its own and no way to touch the outside world: it owns a block of linear memory, it calls the functions its host hands it (its imports) and the host calls the functions it chooses to publish (its exports). Browsers were the first hosts; today Node.js, server-side runtimes (Wasmtime, wazero, WasmEdge, Wasmer), plugin systems and edge platforms run Wasm too. Go compiles to it with the ordinary toolchain, using two ports that differ only in what the host provides: GOOS=js for a JavaScript host and GOOS=wasip1 for a host that implements the WebAssembly System Interface. This page covers both ports, the two compiler directives that connect Go to a host, WASI reactors, the limits of the sandbox, and binary size.

It builds on The go Command, Builds and Cross-Compilation (GOOS, GOARCH, build constraints and -buildmode) and on Goroutines and the Scheduler (a Wasm module runs the whole scheduler on one thread). The related pages are cgo and C Interoperability (the other way out of Go, with the same "pass memory, not objects" discipline), unsafe and Low-Level Programming, Directives, Code Generation and Embedding (where //go:wasmimport and //go:wasmexport are listed among the compiler directives), Testing, I/O, Files and the Filesystem, net/http Clients and Servers, Structs and Embedding (padding and layout) and Memory Management and Garbage Collection.

Other stacks on this site solve the same problem and are worth comparing: Rust WebAssembly (the wasm32-* targets, wasm-bindgen, the component model), Node.js native add-ons, WebAssembly and FFI (the host side: WebAssembly.instantiate and node:wasi), Kotlin/Wasm web targets and Blazor WebAssembly. The foreign-function pages of other languages are Rust FFI and C interop, C# native interop and Swift interoperability with C. For the pointers and memory layout that a host must agree with, see the C Reference pages Pointers and Memory Model and Alignment.

Every example was compiled with Go 1.27.2 and vetted with go vet (with GOOS/GOARCH set for the target, because go vet follows build constraints), and every program was run for real. The hosts used here are Node.js 24 (its built-in node:wasi module for WASI, and wasm_exec.js for the JavaScript port) and a Chromium-based browser for the browser examples. Wasmtime is the host the Go project’s own wrapper script assumes, and it is not installed on the machine used for this page, so the one example that needs it is shown with the real "command not found" result and nothing is claimed about its output. Output that depends on the machine (sizes, timings) is labelled as such. The go.mod of the example module is:

module example.com/shelf

go 1.27

The programs use the catalog package of the shelf module (ParseISBN, ISBN, MemStore, Book), the same catalogue that the rest of this reference uses. Validation is plain Go with no operating-system dependency, which is exactly the kind of code that moves to a Wasm host well.

A Go WebAssembly module between a JavaScript host and a WASI host: imports flow in from the host and the host calls the module’s exports

Two ports, one compiler

Go supports exactly two WebAssembly ports. Both use GOARCH=wasm and produce a standard .wasm module; GOOS selects the interface between the Go runtime and the host.

$ go tool dist list | grep -E 'wasm|wasi'
js/wasm
wasip1/wasm
Aspect js/wasm wasip1/wasm

Since

Go 1.11 (experimental at first)

Go 1.21

Host

a JavaScript environment: a browser, or Node.js, Deno or Bun

a WASI preview 1 runtime: Wasmtime, wazero, WasmEdge, Wasmer, Node.js node:wasi

Support file

wasm_exec.js from $(go env GOROOT)/lib/wasm (misc/wasm before Go 1.24)

none: the host implements wasi_snapshot_preview1

Module imports

gojs.runtime.* and gojs.syscall/js.*

wasi_snapshot_preview1.*

Entry point

run (called by wasm_exec.js)

_start for a command, _initialize for a reactor

Reaching the host

syscall/js: JavaScript values, functions, the DOM, fetch

system calls (files, clock, random, stdio) and //go:wasmimport

Files

a fake file system unless the host provides fs

directories the host preopens

Networking

fake in-memory network; net/http clients use fetch in a browser

fake in-memory network; no real sockets

Exports for the host

not needed: Go registers js.Func values on the global object

//go:wasmexport (Go 1.24) in a reactor

Build tags

js and wasm

wasip1 and wasm

There is no wasip2 port: the component model and WASI preview 2 are not supported by the Go toolchain, and asking for them says so.

$ GOOS=wasip2 GOARCH=wasm go build ./hello
go: unsupported GOOS/GOARCH pair wasip2/wasm

syscall/js is documented as experimental and exempt from the Go compatibility promise: its stated scope is to allow tests to run, not to be a comprehensive API for users. In practice it has been stable for years and the browser frameworks listed on the Go wiki are built on it, but its status is the reason to keep the JavaScript-facing surface of a program small.

The architecture the code sees

On GOARCH=wasm the word size is 64 bits for int, uint and uintptr, while Wasm addresses are 32 bits. That mismatch matters at the boundary and is shown by a measurement in the structs.HostLayout section. The module has one linear memory; it can grow but never shrink. Wasm has no threads here, so runtime.GOMAXPROCS is 1 and goroutines are multiplexed on the single thread, exactly as described in Goroutines and the Scheduler. Go 1.26 changed the runtime to manage heap memory in much smaller chunks on Wasm, which reduces the memory used by applications whose heap is below about 16 MiB, and Go 1.24 reduced the initial memory size, especially for small programs.

The compiler uses the sign-extension and non-trapping float-to-integer instructions unconditionally since Go 1.26; the GOWASM settings signext and satconv that once switched them on are ignored. Go 1.27 adds support for 128-bit Wasm SIMD to the experimental simd/archsimd package (enabled with GOEXPERIMENT=simd, an unstable API that is not covered by the compatibility promise).

Building for WebAssembly

Building is cross-compilation: set GOOS and GOARCH for the command. Only a main package produces a runnable module (building a library gives an object file that cannot run), so code to be reused on Wasm lives in ordinary packages that a small main package wraps.

package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println("hello from", runtime.GOOS+"/"+runtime.GOARCH)
}
$ GOOS=js GOARCH=wasm go build -o hello.wasm ./hello
$ GOOS=wasip1 GOARCH=wasm go build -o hello.wasm ./hello
$ file hello.wasm
hello.wasm: WebAssembly (wasm) binary module version 0x1 (MVP)

The same source builds for both ports and for the native platform. The program was compiled and run for js/wasm (below) and for wasip1/wasm; both print the target they were built for.

Selecting files with build constraints

Platform-specific code uses the ordinary mechanisms from The go Command, Builds and Cross-Compilation: GOOS and GOARCH file-name suffixes and //go:build lines. A js or wasip1 file suffix is a GOOS constraint, and the expression wasm is the GOARCH constraint, so it is true on both ports.

// Package platform reports how shelf reaches its host.
package platform

// Name returns a description of the host interface in use.
func Name() string { return name }
package platform

// The _js suffix is a GOOS constraint: only GOOS=js builds this file.
const name = "JavaScript host (syscall/js)"
package platform

// The _wasip1 suffix is a GOOS constraint, too.
const name = "WASI preview 1 host"
//go:build !js && !wasip1

package platform

const name = "native operating system"
//go:build wasm

package platform

// Memory growth is only observable on GOARCH=wasm.
func init() { _ = name }

These files are platform.go, platform_js.go, platform_wasip1.go, platform_other.go and wasm_only.go. go list shows which files each target selects (this is the quickest way to debug a constraint):

$ for t in darwin/arm64 js/wasm wasip1/wasm linux/amd64; do printf "%s: " $t; GOOS=${t%/*} GOARCH=${t#*/} go list -f '{{.GoFiles}}' ./platform; done
darwin/arm64: [platform.go platform_other.go]
js/wasm: [platform.go platform_js.go wasm_only.go]
wasip1/wasm: [platform.go platform_wasip1.go wasm_only.go]
linux/amd64: [platform.go platform_other.go]

A package that imports syscall/js only builds for js/wasm, so its files carry //go:build js && wasm. Building it for the host system gives the usual error, and go vet reports the same:

$ go build ./js
package example.com/shelf/js: build constraints exclude all Go files in .../g6w8/js

Run GOOS=js GOARCH=wasm go vet ./... (and the wasip1 equivalent) in continuous integration: a plain go vet ./... silently skips the files that the constraints exclude.

The JavaScript port: GOOS=js

GOOS=js GOARCH=wasm produces a module that imports a set of gojs.* functions. The JavaScript side is wasm_exec.js, a support file shipped with the toolchain that defines the global class Go. It builds the import object, supplies the memory-view plumbing, implements the fs and process stubs the Go runtime needs and drives the event loop between JavaScript and Go. Copy it from the same Go release that compiled the module; the Go wiki is explicit that mixing versions is not supported.

$ ls $(go env GOROOT)/lib/wasm
go_js_wasm_exec
go_wasip1_wasm_exec
wasm_exec.js
wasm_exec_node.js

The directory holds wasm_exec.js (about 17 KB, 4 KB gzipped), wasm_exec_node.js (a Node.js launcher) and the two go_*_wasm_exec wrapper scripts that go run and go test use. Before Go 1.24 the same files lived in misc/wasm; older tutorials point there.

The module’s imports and exports show how thin the interface is. The gojs.runtime imports are the clock, the timers, output and random data; the gojs.syscall/js imports are the primitives behind js.Value; and the module exports only what wasm_exec.js needs to start it and resume it after a callback.

imports: gojs.runtime.scheduleTimeoutEvent, gojs.runtime.clearTimeoutEvent, gojs.runtime.resetMemoryDataView,
         gojs.runtime.wasmWrite, gojs.runtime.getRandomData, gojs.runtime.nanotime1, gojs.runtime.wasmExit,
         gojs.runtime.walltime, gojs.syscall/js.finalizeRef, gojs.syscall/js.stringVal, gojs.syscall/js.valueGet,
         gojs.syscall/js.valueSet, gojs.syscall/js.valueIndex, gojs.syscall/js.valueSetIndex,
         gojs.syscall/js.valueLength, gojs.syscall/js.valueCall, gojs.syscall/js.valueNew,
         gojs.syscall/js.valuePrepareString, gojs.syscall/js.valueLoadString, gojs.syscall/js.copyBytesToJS
exports: run, resume, getsp, mem

This listing came from WebAssembly.Module.imports and WebAssembly.Module.exports applied to the shelf.wasm built in the next section.

Running with go run and go test

go run and go test execute a cross-compiled binary through a program named go_$GOOS_$GOARCH_exec found on PATH. Adding lib/wasm to PATH makes go run work for js/wasm with Node.js as the host; -exec names the wrapper explicitly. (The wrapper starts node, so Node.js must be installed.)

$ GOOS=js GOARCH=wasm go run -exec="$(go env GOROOT)/lib/wasm/go_js_wasm_exec" ./hello
hello from js/wasm
$ PATH="$(go env GOROOT)/lib/wasm:$PATH" GOOS=js GOARCH=wasm go run ./hello
hello from js/wasm

Tests work the same way, and the unchanged catalog tests pass inside the Wasm module:

$ PATH="$(go env GOROOT)/lib/wasm:$PATH" GOOS=js GOARCH=wasm go test ./catalog
ok  	example.com/shelf/catalog	0.116s

Exposing Go functions to JavaScript

A Go program cannot export a function by name on the JavaScript port. It registers a js.Func on the JavaScript global object, and JavaScript calls it like any other function. The program below publishes shelfParseISBN and then blocks forever, because the Go program must stay alive for JavaScript to call back into it (the "main must not return" rule is shown further down).

//go:build js && wasm

// Command shelfjs exposes the catalogue's ISBN validation to JavaScript.
package main

import (
	"errors"
	"fmt"
	"syscall/js"

	"example.com/shelf/catalog"
)

// parseISBN adapts catalog.ParseISBN to the func(this, args) shape that
// js.FuncOf requires. It returns a plain JavaScript object.
func parseISBN(this js.Value, args []js.Value) any {
	if len(args) != 1 || args[0].Type() != js.TypeString {
		return js.Global().Get("TypeError").New("parseISBN wants one string")
	}
	isbn, err := catalog.ParseISBN(args[0].String())
	if errors.Is(err, catalog.ErrInvalidISBN) {
		return map[string]any{"ok": false, "error": err.Error()}
	}
	return map[string]any{"ok": true, "isbn": isbn.String()}
}

func main() {
	js.Global().Set("shelfParseISBN", js.FuncOf(parseISBN))
	fmt.Println("shelf: ready")
	select {} // keep the Go runtime alive so JavaScript can call back in
}

The host is a short Node.js script: it loads wasm_exec.js, instantiates the module with the import object that the Go class prepared, starts it with go.run and calls the registered function.

// Host: load wasm_exec.js (from GOROOT/lib/wasm), start the module, call it.
require(process.env.WASM_EXEC);
const fs = require("node:fs");

const go = new Go();
WebAssembly.instantiate(fs.readFileSync("shelf.wasm"), go.importObject).then(({ instance }) => {
  go.run(instance); // returns a promise that settles when main returns
  console.log(shelfParseISBN("978-0-13-468599-1"));
  console.log(shelfParseISBN("978-0-13-468599-2"));
  console.log(shelfParseISBN(42) instanceof TypeError);
  process.exit(0);
});
$ GOOS=js GOARCH=wasm go build -o shelf.wasm ./js
$ WASM_EXEC=$(go env GOROOT)/lib/wasm/wasm_exec.js node js/run.js
shelf: ready
{ ok: true, isbn: '9780134685991' }
{ ok: false, error: 'catalog: invalid ISBN: "978-0-13-468599-2"' }
true

shelf.wasm is 2,576,676 bytes on the machine used for this page (see the size section for what that number means and how to reduce it). Note that go.run(instance) is not awaited before the first call: go.run returns a promise that settles only when the Go main returns, which for this module is never, so await go.run(...) would hang the host forever.

Running in a browser

In a browser the host is a web page. The page below loads wasm_exec.js, streams the module with WebAssembly.instantiateStreaming (which requires the server to send Content-Type: application/wasm; the .wasm extension and the correct type matter), starts it and enables a button once Go has registered its function.

<!doctype html>
<meta charset="utf-8">
<title>shelf wasm</title>
<input id="isbn" value="978-0-13-468599-1">
<button id="check" disabled>Check</button>
<output id="result"></output>
<script src="wasm_exec.js"></script>
<script>
  const go = new Go();
  WebAssembly.instantiateStreaming(fetch("shelf.wasm"), go.importObject).then(({ instance }) => {
    go.run(instance); // do not await: main blocks forever
    const button = document.getElementById("check");
    button.disabled = false;
    button.addEventListener("click", () => {
      const r = shelfParseISBN(document.getElementById("isbn").value);
      document.getElementById("result").textContent = r.ok ? "valid: " + r.isbn : r.error;
    });
  });
</script>

The three files (index.html, wasm_exec.js, shelf.wasm) were served from an 11-line Node.js static server on 127.0.0.1 and the page was loaded in a browser. After the module started the button was enabled; clicking it with the default value produced valid: 9780134685991, and changing the last digit to 2 produced catalog: invalid ISBN: "978-0-13-468599-2". Any static file server works, provided it knows the application/wasm type (http.FileServer in Go does, through the file extension). Opening index.html from a file: URL does not work in browsers that block fetch of local files.

syscall/js: values, types and conversions

syscall/js has a small API built around one type, js.Value, a handle to a JavaScript value.

API Purpose

js.Global()

the global object (window in a browser, globalThis elsewhere)

js.Undefined(), js.Null()

the two "no value" values

js.ValueOf(x)

convert a Go value; see the table below

v.Get(name), v.Set(name, x), v.Delete(name)

property access

v.Index(i), v.SetIndex(i, x), v.Length()

array access

v.Call(name, args...), v.Invoke(args...)

call a method or the value itself

v.New(args...)

construct (js.Global().Get("Date").New())

v.Type(), v.Truthy(), v.IsUndefined(), v.IsNull(), v.IsNaN(), v.InstanceOf(t), v.Equal(w)

inspection

v.Int(), v.Float(), v.Bool(), v.String()

convert back to Go; Int, Float and Bool panic on the wrong type

js.FuncOf(fn) and f.Release()

wrap a Go function as a JavaScript function; release it when unused

js.CopyBytesToGo(dst, src), js.CopyBytesToJS(dst, src)

copy between a Go []byte and a Uint8Array

js.Error, js.ValueError

a JavaScript Error as a Go error; the type of the conversion panic

js.ValueOf accepts a closed set of Go types and panics on anything else (a struct, a typed slice, a channel):

Go JavaScript

js.Value

the value itself

js.Func

function

nil

null

bool

boolean

integers and floats

number

string

string

[]any

a new array

map[string]any

a new object

A value that crosses the boundary is copied or wrapped, never shared: a []any becomes a fresh JavaScript array, so mutating the array in JavaScript does not touch the Go slice. Strings are copied; bytes are copied explicitly with CopyBytesToGo and CopyBytesToJS; and a js.Value is only a reference into the JavaScript heap, which the host keeps alive until the Go value is garbage-collected.

The wrong-type conversions panic with a js.ValueError. This program prints the types, then converts a string to an int:

//go:build js && wasm

package main

import (
	"fmt"
	"syscall/js"
)

func main() {
	fmt.Println(js.ValueOf(map[string]any{"n": 1, "s": "x"}).Type(), js.ValueOf([]any{1, "a"}).Length())
	v := js.ValueOf("not a number")
	fmt.Println(v.Type(), js.Undefined().Type(), js.Null().Type(), js.ValueOf(1.5).Type(), js.ValueOf(true).Type())
	fmt.Println(v.Int())
}
object 2
string undefined null number boolean
panic: syscall/js: call of Value.Int on string

goroutine 1 [running]:
syscall/js.Value.float({{}, 0x7ff8000200000016, 0x20e0b8}, {0x6f45, 0x9})
	.../src/syscall/js/js.go:523 +0x10
syscall/js.Value.Int(...)
	.../src/syscall/js/js.go:540
main.main()
	.../js3/main.go:14 +0x2c
exit code: 2

A panic prints the usual traceback and the host sees the exit code (wasm_exec.js prints exit code: 2 when the Go object’s exit hook is not overridden). Check Type() before converting values that come from untrusted JavaScript, exactly as parseISBN does above with args[0].Type() != js.TypeString.

Callbacks, promises and goroutines

A js.Func is invoked by the JavaScript event loop. The documentation spells out the consequence: invoking the wrapped function pauses the event loop and runs the Go function on a new goroutine, so if a wrapped function blocks, JavaScript’s event loop is blocked until it returns. Anything that needs the event loop to make progress (a fetch, a timer-based wait on the JavaScript side, a promise that resolves later) deadlocks if the callback waits for it. The rule is: a blocking function starts a goroutine and returns immediately, handing the result back through a Promise.

The next program shows the three common patterns: returning a Promise from Go, passing bytes in, and calling a JavaScript method (console.log) from Go.

//go:build js && wasm

// Command shelfjs2 shows Go calling JavaScript: a promise-returning lookup,
// a callback, byte-array copying and a call to console.log.
package main

import (
	"fmt"
	"syscall/js"
	"time"

	"example.com/shelf/catalog"
)

var store = catalog.NewMemStore()

// lookup returns a Promise. The Go work runs in its own goroutine, because
// a js.Func callback must not block waiting on the JavaScript event loop.
func lookup(this js.Value, args []js.Value) any {
	isbn := catalog.ISBN(args[0].String())
	handler := js.FuncOf(func(_ js.Value, p []js.Value) any {
		resolve, reject := p[0], p[1]
		go func() {
			time.Sleep(10 * time.Millisecond) // pretend to wait for I/O
			b, ok := store.Get(isbn)
			if !ok {
				reject.Invoke(js.Global().Get("Error").New("not found: " + string(isbn)))
				return
			}
			resolve.Invoke(b.Title)
		}()
		return nil
	})
	// The executor has already run by the time New returns, so release it.
	defer handler.Release()
	return js.Global().Get("Promise").New(handler)
}

// checksum copies a Uint8Array into Go memory and back out again.
func checksum(this js.Value, args []js.Value) any {
	buf := make([]byte, args[0].Get("length").Int())
	n := js.CopyBytesToGo(buf, args[0])
	var sum byte
	for _, b := range buf[:n] {
		sum += b
	}
	return int(sum)
}

func main() {
	store.Put(catalog.Book{ISBN: "9780134685991", Title: "Effective Java"})
	js.Global().Set("shelfLookup", js.FuncOf(lookup))
	js.Global().Set("shelfChecksum", js.FuncOf(checksum))
	console := js.Global().Get("console")
	console.Call("log", "hello from Go:", fmt.Sprintf("%d books", len(store.All())))
	select {}
}
require(process.env.WASM_EXEC);
const fs = require("node:fs");

const go = new Go();
WebAssembly.instantiate(fs.readFileSync("shelf2.wasm"), go.importObject).then(async ({ instance }) => {
  go.run(instance);
  console.log(await shelfLookup("9780134685991"));
  await shelfLookup("0000000000000").catch((e) => console.log("rejected:", e.message));
  console.log(shelfChecksum(new Uint8Array([1, 2, 3, 200])));
  process.exit(0);
});
hello from Go: 1 books
Effective Java
rejected: not found: 0000000000000
206

Three details of this program are the usual sources of leaks and crashes:

  • js.FuncOf allocates a table entry that is never garbage-collected: call Release() when the function will not be called again. The Promise executor is called synchronously by the Promise constructor, so it is released with defer right after New. The long-lived functions published on the global object are never released, which is intended.

  • The resolve and reject values are js.Value handles, valid to call from the goroutine because JavaScript’s single thread is only entered when Go calls into it.

  • time.Sleep inside the goroutine does not block the event loop: the Go timer is implemented with the host’s setTimeout (the scheduleTimeoutEvent import listed earlier).

Calling a released function from JavaScript does not panic in Go: the host logs call to released function and the call returns undefined, which is easy to miss. Release a function only when nothing can still call it.

main must not return

When main returns the Go program has exited. wasm_exec.js marks the Go object as exited and every later call into Go fails. That is why the programs above end in select {} (or block on a channel that a shutdown function closes).

//go:build js && wasm

package main

import "syscall/js"

func main() {
	js.Global().Set("hi", js.FuncOf(func(this js.Value, args []js.Value) any { return "hi" }))
	// main returns: the Go program has exited.
}
require(process.env.WASM_EXEC);
const fs = require("node:fs");
const go = new Go();
WebAssembly.instantiate(fs.readFileSync("js4.wasm"), go.importObject).then(async ({ instance }) => {
  await go.run(instance);
  console.log("exit code:", go.exited);
  try { console.log(hi()); } catch (e) { console.log("error:", e.message); }
});
exit code: true
error: Go program has already exited

The promise returned by go.run settles when main returns, which is how a command-line Go program built for js/wasm signals completion to its host. The Go object also has hooks a host can set before run: argv (the program arguments, default ["js"]), env (the environment) and exit (called with the exit code). The launcher wasm_exec_node.js sets all three from the Node.js process.

Source: syscall/js

net/http in the browser, and in Node.js

On js/wasm the default http.Transport is replaced by one implemented with the host’s fetch function, so an ordinary http.Client works in a browser subject to the browser’s rules. CORS applies exactly as it does to JavaScript: a cross-origin request needs a response with the right Access-Control-Allow-Origin header (see CORS for the protocol). A Go program cannot listen: a browser tab cannot accept connections, and net.Listen only reaches the in-memory network described later.

//go:build js && wasm

// Command shelffetch uses net/http inside WebAssembly: in a browser the
// default transport is implemented with the host's fetch function.
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"syscall/js"
	"time"
)

func main() {
	// Ask the page where it was loaded from; a relative URL would be rejected.
	origin := js.Global().Get("location").Get("origin").String()

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, origin+"/books/9780134685991", nil)
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		fmt.Println("request:", err)
		os.Exit(1)
	}
	defer resp.Body.Close()

	var book struct {
		ISBN  string `json:"isbn"`
		Title string `json:"title"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&book); err != nil {
		fmt.Println("decode:", err)
		os.Exit(1)
	}
	fmt.Println(resp.Status, book.ISBN, book.Title)
}

Loaded in a browser from a local server that answers /books/<isbn> with a JSON object, with console.log mirrored into a <pre> element, the page showed:

200 OK 9780134685991 Effective Java
done

The context passed to the request is honoured: cancelling it aborts the underlying fetch through an AbortController when the browser has one. Three behaviours are worth knowing before relying on this:

  • fetch options that have no net/http equivalent are passed as request headers whose names start with js.fetch: (js.fetch:mode, js.fetch:credentials, js.fetch:redirect); the transport removes them before sending. They are documented in the source of the transport. The credentials default is same-origin.

  • Under Node.js the transport deliberately does not use fetch: it detects Node.js and falls back to the same in-memory fake network that the tests of the standard library use, so a http.Get to a real server from a js/wasm program running in Node.js does not reach it. Do real network work in the browser, or pass the data in from JavaScript.

  • A custom http.Transport that sets DialContext and friends also bypasses fetch and uses the fake network.

Browser frameworks and the DOM

The DOM is reached with js.Global().Get("document") and the methods of js.Value. That is verbose, which is why the Go wiki lists community libraries (Vecty, Vugu, app, DOM binding generators) that wrap syscall/js. Treat them as optional, community-maintained dependencies: none ships with Go, and their release cadence is independent of the toolchain. Because wasm_exec.js and the compiler must come from the same Go release, upgrading Go means rebuilding every module and republishing the matching wasm_exec.js; a stale cached copy of either one is the most common cause of "WebAssembly.instantiate(): Import #0 module="gojs" function=…​" link errors after an upgrade.

The WASI port: GOOS=wasip1

WASI, the WebAssembly System Interface, defines how a Wasm module reaches the operating system: arguments, environment variables, files, clocks, random data, standard streams and process exit. Go targets the first released version, preview 1, whose module name is wasi_snapshot_preview1 and which gave the port its wasip1 name. The blog post introducing the port notes that new WASI versions will likely mean new GOOS values, which is why a preview 2 target is a different, so-far-absent port. WASI is capability based: the host decides which directories, environment variables and streams the module can see; nothing is ambient.

A WASI module that imports almost nothing is already fully functional:

empty wasip1 module: 10 imports, exports: _start, memory

The ten imports are the preview 1 functions the runtime always needs: args_get, args_sizes_get, environ_get, environ_sizes_get, clock_time_get, random_get, fd_write, poll_oneoff, sched_yield and proc_exit. A program that reads and writes files imports more (the reactor below imports fifteen wasi_snapshot_preview1 functions, adding the file-descriptor and preopen calls the runtime uses to initialize standard streams). Everything else is Go code compiled into the module, including the scheduler, the garbage collector and the standard library; that is why even an empty program is about 1.9 MB.

A command: standard input, arguments and a preopened directory

A WASI command has a _start entry point and runs main to completion, like an ordinary process. The following program reads ISBNs from standard input, validates them with the catalog package and writes a report file. It is ordinary Go: there is nothing Wasm-specific in it.

// Command isbncheck validates ISBNs read from standard input. It is an
// ordinary Go program: it also builds for wasip1/wasm with no changes.
package main

import (
	"bufio"
	"fmt"
	"os"
	"path/filepath"
	"runtime"

	"example.com/shelf/catalog"
)

func main() {
	fmt.Printf("platform: %s/%s, args: %q\n", runtime.GOOS, runtime.GOARCH, os.Args[1:])

	valid, invalid := 0, 0
	sc := bufio.NewScanner(os.Stdin)
	for sc.Scan() {
		if _, err := catalog.ParseISBN(sc.Text()); err != nil {
			invalid++
			fmt.Fprintln(os.Stderr, "invalid:", err)
			continue
		}
		valid++
	}
	if err := sc.Err(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}

	// The WASI host decides which directories exist at all.
	dir := os.Getenv("SHELF_DATA")
	if dir == "" {
		dir = os.TempDir()
	}
	report := fmt.Sprintf("valid=%d invalid=%d\n", valid, invalid)
	if err := os.WriteFile(filepath.Join(dir, "report.txt"), []byte(report), 0o644); err != nil {
		fmt.Fprintln(os.Stderr, "write report:", err)
		os.Exit(2)
	}
	fmt.Print(report)
}

The host here is a minimal script around Node’s built-in node:wasi module. It preopens one real directory as /data inside the module and passes SHELF_DATA=/data in the environment; the module sees no other directory.

// Minimal WASI host for a Go wasip1 command, using Node's built-in node:wasi.
import { readFileSync } from "node:fs";
import { WASI } from "node:wasi";

const [, , file, ...args] = process.argv;
const wasi = new WASI({
  version: "preview1",
  args: [file, ...args],
  env: { SHELF_DATA: process.env.GUEST_DIR ?? "/data" },
  preopens: { "/data": process.env.DATA_DIR },
});
const { instance } = await WebAssembly.instantiate(readFileSync(file), wasi.getImportObject());
process.exitCode = wasi.start(instance);
$ GOOS=wasip1 GOARCH=wasm go build -o isbncheck.wasm ./cmdwasi
$ printf '978-0-13-468599-1\n978-0-13-468599-2\n' | DATA_DIR=$PWD/data node --no-warnings wasi/run.mjs isbncheck.wasm a b
platform: wasip1/wasm, args: ["a" "b"]
invalid: catalog: invalid ISBN: "978-0-13-468599-2"
valid=1 invalid=1
$ cat data/report.txt
valid=1 invalid=1

Standard input, standard error, the arguments (a b), the environment variable and the file write all went through WASI calls (fd_read, fd_write, args_get, environ_get, path_open); the program contains no //go:wasmimport and no host-specific code. When the host does not preopen the directory, the same module fails with the error of a missing capability, not with a security exception:

$ printf '9780134685991\n' | DATA_DIR=$PWD/data GUEST_DIR=/nope node --no-warnings wasi/run.mjs isbncheck.wasm
platform: wasip1/wasm, args: []
write report: open /nope/report.txt: Invalid argument
$ echo $?
2

In that run /nope was set as SHELF_DATA while only /data was preopened; a module with no preopens at all fails the same way for every path. The lesson for tests and for ports is that a file name must be resolved relative to what the host granted. os.TempDir() inside a WASI module is a path in the guest’s view, not the host’s, and it exists only if the host preopened it. Go 1.24’s os.Root (see I/O, Files and the Filesystem) pairs naturally with this model, because it restricts access to a directory tree in the same way.

The node:wasi module is marked experimental, and its documentation states that its capability features "do not form a security model": the file-system sandboxing can be escaped, so it should not be used to run untrusted code. The Go module is only as isolated as the host that runs it; Wasmtime, wazero, WasmEdge and Wasmer implement the capability model with stronger guarantees.

go run and go test with a WASI host

The toolchain ships go_wasip1_wasm_exec, a wrapper that go run and go test use when lib/wasm is on PATH or when -exec names it. The script picks the host from GOWASIRUNTIME; the supported values are wasmtime (the default), wazero, wasmedge and wasmer, and GOWASIRUNTIMEARGS passes extra arguments. It starts the module with the whole file system mapped (--dir=/) and an enlarged stack.

$ GOOS=wasip1 GOARCH=wasm go run -exec="$(go env GOROOT)/lib/wasm/go_wasip1_wasm_exec" ./hello
/Users/.../sdk/go1.27.2/lib/wasm/go_wasip1_wasm_exec: line 17: exec: wasmtime: not found
exit status 127

That is the real result on the machine used for this page, which has no Wasmtime: this example was not run with Wasmtime, and no Wasmtime output is claimed. With Wasmtime installed the same command prints the greeting. Nothing in the pipeline needs the project’s script, though: -exec accepts any program that takes the binary and its arguments, so a three-line Node.js launcher works just as well, and it is what the next example uses.

#!/usr/bin/env node
// A go test -exec wrapper: run the test binary with Node's WASI host.
import { readFileSync } from "node:fs";
import { WASI } from "node:wasi";

const [, , file, ...args] = process.argv;
const wasi = new WASI({
  version: "preview1",
  args: [file, ...args],
  env: process.env,
  preopens: { "/": "/" },
  returnOnExit: true,
});
const { instance } = await WebAssembly.instantiate(readFileSync(file), wasi.getImportObject());
process.exitCode = wasi.start(instance);
$ GOOS=wasip1 GOARCH=wasm go run -exec="$PWD/wasi/node_wasi_exec.sh" ./hello
hello from wasip1/wasm
$ GOOS=wasip1 GOARCH=wasm go test -exec="$PWD/wasi/node_wasi_exec.sh" ./catalog
ok  	example.com/shelf/catalog	0.096s

(node_wasi_exec.sh is a one-line shell script that runs node --no-warnings on the .mjs file above.) The unchanged catalog tests pass in a WASI module, which is the practical test that a package is portable: running its suite on both wasip1 and js catches accidental dependence on threads, processes and sockets.

What the sandbox does not give you

Both ports run the full Go runtime, but the host defines the world. This program probes five capabilities: parallelism, goroutines and timers, process execution, networking and a connection to an address outside the module.

package main

import (
	"fmt"
	"io"
	"net"
	"os"
	"os/exec"
	"runtime"
	"sync"
	"time"
)

func main() {
	fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0), "NumCPU:", runtime.NumCPU())

	var wg sync.WaitGroup
	var mu sync.Mutex
	total := 0
	for i := 1; i <= 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			time.Sleep(time.Millisecond)
			mu.Lock()
			total += i
			mu.Unlock()
		}()
	}
	wg.Wait()
	fmt.Println("goroutines and timers work, total =", total)

	cmd := exec.Command("/usr/bin/true")
	cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
	fmt.Println("os/exec:", cmd.Run())

	// Listen and dial both succeed, but only between goroutines of this
	// one module: the standard library fakes the network in memory.
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		fmt.Println("listen:", err)
		return
	}
	go func() {
		c, _ := ln.Accept()
		io.WriteString(c, "hello over the fake network\n")
		c.Close()
	}()
	c, err := net.Dial("tcp", ln.Addr().String())
	if err != nil {
		fmt.Println("dial:", err)
		return
	}
	msg, _ := io.ReadAll(c)
	fmt.Print(string(msg))

	_, err = net.DialTimeout("tcp", "93.184.216.34:80", time.Second)
	fmt.Println("dial outside:", err)
}

The output on wasip1/wasm (run in Node’s WASI host), on js/wasm and, for the third line, on the native platform:

GOMAXPROCS: 1 NumCPU: 1
goroutines and timers work, total = 5050
os/exec: fork/exec /usr/bin/true: Not implemented on wasip1
hello over the fake network
dial outside: dial tcp 93.184.216.34:80: address 93.184.216.34:80: connect: Connection refused

On js/wasm the process line reads fork/exec /usr/bin/true: not implemented on js; natively os/exec succeeds (<nil>) and the final line is an i/o timeout from a real dial attempt. (The native run has GOMAXPROCS equal to the number of CPUs.) The practical reading:

Capability On Wasm

Parallelism

none: one thread, GOMAXPROCS is 1; goroutines interleave, so sync primitives and channels work, but nothing runs at the same time

Goroutines, timers, select, context

work; a goroutine that is blocked in a host call blocks every goroutine until the call returns

os/exec, fork

not available (syscall.ForkExec and friends report "not implemented")

Files

js: a stub; wasip1: only what the host preopens

Sockets

no real ones. The standard library implements a fake in-memory network "to allow tests of other packages to pass"; listening and dialling work between goroutines of the same module and nothing else

Listening servers

a net/http server runs but only serves requests that come from the same module; a real server needs a host that hands it a socket

Environment

js: go.env; wasip1: environ_get

Cgo

not available: import "C" fails to build (see below)

WASI preview 1 defines operations on already opened sockets but no way to open one, so a network server needs a host extension (Wasmer and WasmEdge have them) and a library that bridges to it through //go:wasmimport; the Go blog names the community stealthrocket/net package as one such bridge. A host that preopens a listening socket can also hand it to the program as an os.File that net.FileListener turns into a listener; that path is host-specific and not exercised here. Cgo is not an option either; asking for it fails inside the toolchain, not in your code:

$ CGO_ENABLED=1 GOOS=wasip1 GOARCH=wasm go build ./t2
# runtime/cgo
cgo: unknown ptrSize for $GOARCH "wasm"

(The program is a one-function C preamble with import "C"; GOOS=js fails the same way.) Where a design needs real sockets, treat the Wasm module as a function that the host drives (a reactor, below) and keep I/O in the host.

//go:wasmimport and //go:wasmexport

The two directives connect Go to arbitrary hosts, in both directions, without wasm_exec.js or a particular WASI runtime.

//go:wasmimport module name (Go 1.21) must precede a function declaration with no body. A call to that function becomes a call to the function name of the import module module, which the host must provide. This is how the standard library itself reaches WASI: the random_get wrapper in the runtime is declared with //go:wasmimport wasi_snapshot_preview1 random_get.

//go:wasmexport name (Go 1.24) precedes a function definition and makes it callable from the host under the export name name. It is the Wasm analogue of cgo’s //export, with a simpler mechanism.

Both directives follow one type mapping. Parameters and results must be one of these Go types, and anything else is rejected by the compiler:

Go type Wasm type Notes

bool

i32

int32, uint32

i32

int64, uint64

i64

float32

f32

float64

f64

uintptr

i32

permitted since Go 1.24 (release notes), although the table in the compiler documentation does not list it

unsafe.Pointer

i32

an address in linear memory

pointer

i32

element type restricted, see below

string

(i32, i32)

pointer and length; only as a parameter, never as a result

A pointer’s element type must be a bool, one of the sized integers, float32, float64, an array of those, or a struct that embeds structs.HostLayout and contains only such fields. Go’s int and uint are not in the table: they are 64 bits on this architecture and a host that thinks in i32 would misread them, so the compiler makes you choose a size (go:wasmexport: unsupported parameter type int). Slices, maps, interfaces and structures that contain pointers cannot cross, because the host cannot interpret Go’s internal representations. The blog post gives the reason: the module is a 64-bit program in a 32-bit address space, so pointers stored inside memory cannot be passed.

Exceeding the rules is a compile-time error with the offending line (GOOS=wasip1 GOARCH=wasm go build ./bad; go vet adds nothing, because the compiler already refuses the program):

package main

//go:wasmexport greet
func Greet(name string) string { return "hello " + name }

//go:wasmimport shelf_host title
func hostTitle(isbn int32) string

func main() {}
# example.com/shelf/bad
bad/main.go:4:6: go:wasmexport: unsupported result type string
bad/main.go:7:6: go:wasmimport: unsupported result type string

The fix is to follow the C convention that Wasm hosts expect: the host allocates (or the module exports an allocator) and the call passes a pointer and a length for the buffer to fill. The reactor example below shows both directions.

Memory layout: structs.HostLayout

A pointer to a struct crosses the boundary as an offset into linear memory, and the host reads the fields at fixed offsets. Go does not promise a layout for ordinary structs (the compiler may reorder or pad them differently in a future release), so a struct that the host reads or writes embeds structs.HostLayout (Go 1.23) as a blank first field. The marker asks for the layout the host’s C ABI expects: fields in declaration order with C-style padding. The type affects only the struct that embeds it, not struct-typed fields inside it, and it is the type system’s way to say "this struct is a wire format".

The module below exports a function that takes a pointer to a Loan that the host wrote into the module’s memory, plus two functions that let the host verify its assumptions.

//go:build wasip1

package main

import (
	"structs"
	"unsafe"
)

// Loan has a layout the host can rely on: fields in order, C-style padding.
type Loan struct {
	_    structs.HostLayout
	Days int32   // offset 0
	Rate float32 // offset 4
}

// Fine computes the overdue fine for a loan the host wrote into our memory.
//
//go:wasmexport fine
func Fine(l *Loan) float32 { return float32(l.Days) * l.Rate }

// Layout lets the host check its assumptions at run time.
//
//go:wasmexport sizeof_int
func SizeofInt() int32 { return int32(unsafe.Sizeof(int(0))) }

//go:wasmexport sizeof_loan
func SizeofLoan() int32 { return int32(unsafe.Sizeof(Loan{})) }

//go:wasmexport new_loan
func NewLoan() *Loan { l := new(Loan); keep = l; return l }

var keep *Loan

func main() {}

The host, again Node.js, allocates a Loan through new_loan, writes the two fields with a DataView (Wasm is little-endian) and calls fine with the offset:

import { readFileSync } from "node:fs";
import { WASI } from "node:wasi";

const wasi = new WASI({ version: "preview1" });
const { instance } = await WebAssembly.instantiate(readFileSync("layout.wasm"), wasi.getImportObject());
wasi.initialize(instance);
const { memory, new_loan, fine, sizeof_int, sizeof_loan } = instance.exports;

console.log("sizeof(int) in the module:", sizeof_int(), "bytes");
console.log("sizeof(Loan):", sizeof_loan(), "bytes");

const ptr = new_loan(); // an i32 offset into linear memory
const view = new DataView(memory.buffer, ptr, sizeof_loan());
view.setInt32(0, 12, true); // Days (wasm is little-endian)
view.setFloat32(4, 0.25, true); // Rate
console.log("fine:", fine(ptr));
$ GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o layout.wasm ./layout
$ node --no-warnings layout/host.mjs
sizeof(int) in the module: 8 bytes
sizeof(Loan): 8 bytes
fine: 3

sizeof(int) is 8 while the pointers are 32-bit offsets: the line that shows why int is not a legal parameter type. The struct is 8 bytes (an int32 and a float32 have no padding between them) and fine returned 12 * 0.25 = 3. Without the HostLayout marker the compiler refuses the pointer parameter, which is the safeguard that makes the contract explicit:

t1/main.go:11:6: go:wasmexport: unsupported parameter type *Loan

The layout rules themselves are those of Structs and Embedding and of C structs (Memory Model and Alignment); in Rust the equivalent is #[repr(C)] (Memory Layout and repr).

Reactors: a Go library for a Wasm host

A command runs main and finishes. A reactor (or library) is a module that stays alive after initialization and whose exports the host calls repeatedly: the shape of a plugin. Go builds one with -buildmode=c-shared (Go 1.24). The linker does not generate _start and generates _initialize instead, which initializes the Go runtime and the packages' init functions. The host must call _initialize before any other export, and main is never called. The same flag on a native target builds a C shared library; the go command help states that on wasip1 it builds a WASI reactor whose callable symbols are the //go:wasmexport functions.

The reactor below validates ISBNs for its host and keeps a count. It uses all four mechanisms in one place: an imported host function (shelf_host.log, with a string parameter), exports that take a pointer and a length, state that lives in the instance, and an allocator so the host can put bytes into the module’s memory.

//go:build wasip1

// Command react is a WASI reactor: a library the host calls into many times.
package main

import (
	"unsafe"

	"example.com/shelf/catalog"
)

// The host supplies this function. The module name and function name are
// whatever the host registers in its import object.
//
//go:wasmimport shelf_host log
func hostLog(msg string)

// validCount is state that lives as long as the instance.
var validCount int32

// CheckISBN is called by the host with a pointer and length into linear
// memory. It returns 1 for a valid ISBN, 0 otherwise.
//
//go:wasmexport check_isbn
func CheckISBN(ptr *byte, n int32) int32 {
	s := unsafe.String(ptr, int(n))
	if _, err := catalog.ParseISBN(s); err != nil {
		hostLog("rejected " + s)
		return 0
	}
	validCount++
	return 1
}

// Valid reports how many valid ISBNs this instance has accepted.
//
//go:wasmexport valid_count
func Valid() int32 { return validCount }

// Alloc hands the host a buffer inside the module's own memory.
//
//go:wasmexport alloc
func Alloc(n int32) *byte {
	buf := make([]byte, n)
	pinned = append(pinned, buf) // keep it reachable until Free
	return &buf[0]
}

//go:wasmexport free
func Free() { pinned = nil }

var pinned [][]byte

// main is never run in a reactor, but package main needs one.
func main() {}

The host registers shelf_host.log in the import object, instantiates the module, calls wasi.initialize (which runs _initialize), then drives the exports. To pass a string it asks the module for a buffer, copies the UTF-8 bytes into linear memory and calls check_isbn with the buffer’s offset and length.

import { readFileSync } from "node:fs";
import { WASI } from "node:wasi";

const wasi = new WASI({ version: "preview1" });
let memory;
const decoder = new TextDecoder();

const imports = {
  ...wasi.getImportObject(),
  shelf_host: {
    // (ptr, len) pair: how a Go string crosses the boundary.
    log(ptr, len) {
      console.log("[host] " + decoder.decode(new Uint8Array(memory.buffer, ptr, len)));
    },
  },
};

const { instance } = await WebAssembly.instantiate(readFileSync("shelf.reactor.wasm"), imports);
memory = instance.exports.memory;
wasi.initialize(instance); // runs _initialize: starts the Go runtime, no main

const { check_isbn, valid_count, alloc, free } = instance.exports;
const encoder = new TextEncoder();

function check(isbn) {
  const bytes = encoder.encode(isbn);
  const ptr = alloc(bytes.length);
  new Uint8Array(memory.buffer, ptr, bytes.length).set(bytes);
  const ok = check_isbn(ptr, bytes.length);
  free();
  return ok === 1;
}

console.log(check("978-0-13-468599-1"));
console.log(check("978-0-13-468599-2"));
console.log(check("9780134685991"));
console.log("valid:", valid_count());
console.log(Object.keys(instance.exports).sort().join(", "));
$ GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o shelf.reactor.wasm ./react
$ node --no-warnings react/host.mjs
true
[host] rejected 978-0-13-468599-2
false
true
valid: 2
_initialize, alloc, check_isbn, free, memory, valid_count

The state (valid: 2) lived across calls: the instance is long-lived. The export list at the end is the whole public surface: _initialize plus the four functions and the memory. There is no _start, and there is no main export.

The details that decide whether a reactor works:

  • Initialization order. Calling an export before _initialize fails. In the real run below the Go runtime detects it and tries to print an error, which Node’s WASI refuses because it has not been started:

    Error: wasi.start() has not been called
        at runtime.fd_write (wasm://wasm/0099a78e:wasm-function[640]:0x9fcda)
        at runtime.write1 (wasm://wasm/0099a78e:wasm-function[643]:0x9fe35)
        at runtime.write (wasm://wasm/0099a78e:wasm-function[1292]:0x12e6cc)
        at runtime.writeErrData (wasm://wasm/0099a78e:wasm-function[855]:0xd4d8c)
        at runtime.notInitialized1 (wasm://wasm/0099a78e:wasm-function[931]:0xebbda)
        at runtime.notInitialized (wasm://wasm/0099a78e:wasm-function[1354]:0x13286f)
        at valid_count (wasm://wasm/0099a78e:wasm-function[1639]:0x17d9d3)

    The runtime.notInitialized frame is Go’s own check; the message is the host’s. In the Node.js API the reactor entry point is wasi.initialize(instance), whereas a command uses wasi.start(instance).

  • Memory ownership. The host cannot call malloc in a Go module. Export an allocator (alloc) that returns a pointer into a Go-owned buffer and keep a reference to it (pinned) so that the garbage collector does not free it while the host writes. Go’s collector does not move heap objects, so the offset stays valid; the explicit free drops the reference.

  • Views are invalidated by growth. memory.buffer is detached when the module grows its memory (an allocation can do that), so a host must build Uint8Array and DataView objects after the calls that may allocate, as check does, and never cache them across calls.

  • Goroutines. A //go:wasmexport function may start goroutines, but there is only one thread: a background goroutine does not keep running after the export returns, and resumes only when the host calls into the module again. Export functions that finish their work (or block on a channel) before returning; do not depend on background work.

  • Strings. The imported hostLog(msg string) passes a pointer and length pair, exactly what the host’s log(ptr, len) receives; a string result would need a second export that the host calls to fetch the bytes.

  • One host call at a time. While a host function runs, every goroutine is blocked; a long-running import stalls the module.

The blog post’s own example drives a reactor from Go with the wazero runtime: it instantiates the module with WithStartFunctions("_initialize") and calls ExportedFunction("add"). wazero and the other Go-written runtimes are third-party modules and are not used here; the equivalent host in Rust is Wasmtime’s API (see Rust WebAssembly for the Rust side of the same exercise).

Performance and the runtime

A Wasm module executes more slowly than native code: the host’s Wasm engine compiles the module to machine code, and the Go runtime (scheduler, garbage collector) runs inside the module on one thread. This benchmark of the ISBN parser uses b.Loop (Go 1.24) and runs the same test binary natively and under both hosts:

package bench

import (
	"testing"

	"example.com/shelf/catalog"
)

func BenchmarkParseISBN(b *testing.B) {
	for b.Loop() {
		if _, err := catalog.ParseISBN("978-0-13-468599-1"); err != nil {
			b.Fatal(err)
		}
	}
}
native arm64 (darwin):      BenchmarkParseISBN-10   13875117    79.46 ns/op
wasip1/wasm (Node 24 WASI): BenchmarkParseISBN       4307470   275.5  ns/op
js/wasm (Node 24):          BenchmarkParseISBN       3708852   344.0  ns/op

Source: testing: B.Loop

These timings are machine-specific and are given only to show the order of magnitude: three to four times slower than native for this allocation-heavy loop, with js/wasm somewhat slower than wasip1/wasm (the JavaScript port pays for the extra gojs glue on every runtime call). Treat them as a sanity check, not a verdict: the right measurement uses the host and the workload you ship (see Benchmarks, Fuzzing and synctest).

Runtime properties that follow from the design:

  • One thread. The scheduler multiplexes every goroutine on a single thread. Code that parallelizes work across cores on a server gets no speed-up, and sync and channels are cheap because nothing contends.

  • Garbage collection runs in the module. The collector is the same one described in Memory Management and Garbage Collection and its pauses are the host thread’s pauses; a JavaScript page that hosts it freezes while it runs. GOGC and GOMEMLIMIT apply (set them through the environment the host provides).

  • Linear memory grows, and never shrinks. A Wasm memory can only grow; a module that briefly needed 400 MiB keeps it. Go 1.26 reduced the memory footprint of small heaps, but the rule remains: size the working set for the host.

  • Host calls are expensive. Crossing the boundary has a fixed cost and, for js/wasm, each js.Value operation is a call into JavaScript. Batch the work: pass a whole slice (as bytes) rather than calling a function per element.

Binary size

Go’s runtime and standard library are compiled into every module, which makes Go Wasm binaries large compared with languages that rely on a host runtime. Real sizes on the machine used for this page (machine-specific, Go 1.27.2, wasip1/wasm, bytes):

Program Default build With -ldflags='-s -w' gzip -9 of the default build

empty main

1,910,002

1,875,123

562,870

fmt.Println("hello")

2,480,097

2,435,566

716,790

ISBN validator (catalog, os)

2,514,419

2,469,555

725,725

net/http imported (http.StatusText)

4,875,526

4,786,524

1,388,284

js/wasm module that registers shelfParseISBN

2,576,676

2,530,666

740,735

For comparison the same fmt program built for the native darwin/arm64 platform is 2,429,714 bytes: a Go Wasm module is about the size of the native executable. What reduces the figure, in decreasing order of effect:

  • Compress for delivery. Servers and CDNs serve .wasm files with gzip or Brotli; the numbers above show a 3.4x reduction with gzip level 9 (Brotli is usually somewhat better; it was not available for measurement here). This is the largest single win and costs nothing in the build.

  • Import less. Importing net/http doubled the module (go list -deps shows 59 packages for the fmt program, 61 for the ISBN validator and 184 for the net/http one); encoding/json (reflection), text/template and regexp are the other usual suspects. go list -deps with the target’s GOOS and GOARCH shows what a package pulls in; go tool nm cannot read a Wasm module, so run it on a native build of the same program to see the largest symbols.

  • Strip debug information. -ldflags='-s -w' removes the symbol table and DWARF data: a 1 to 2 percent reduction here, because Wasm binaries carry little of it. -trimpath shortens embedded file paths (a further 9 KB, 0.4 percent, on the ISBN validator, which went from 2,469,555 to 2,460,417 bytes), and it makes builds reproducible.

  • Post-process. The community tool wasm-opt (from Binaryen) rewrites a module to be smaller and often faster, and wasm-strip removes names and custom sections. Neither is a Go tool and neither was installed here, so their effect is not measured on this page.

  • Use a different compiler. TinyGo (https://tinygo.org) is a community Go compiler for small targets with its own runtime that advertises WebAssembly output far smaller than the standard toolchain’s. It trades completeness for size: not every standard library package or reflect feature is supported, and it ships its own JavaScript support file, which is not interchangeable with Go’s wasm_exec.js. Evaluate it against your dependencies; it was not installed, used or measured for this page.

  • Load lazily. A browser can stream-compile (instantiateStreaming) while the download continues, and the page can show a placeholder until the module is ready; in a service worker, cache the module.

Choosing between js, wasip1 and not using Wasm

Goal Reasonable choice

run validation or parsing logic in a web page

GOOS=js with a small exported surface (js.FuncOf), or keep the logic on the server

run a Go command on a Wasm/WASI runtime, in a container-like sandbox

GOOS=wasip1 command; configure preopens, environment and arguments explicitly

let a host application call Go as a plugin, many times

GOOS=wasip1 reactor with //go:wasmexport; design exports with integers, floats and pointer-length pairs

reuse Go code from Node.js without a native add-on

GOOS=js with wasm_exec.js, or a wasip1 module run with node:wasi

a small module for the browser

consider Rust (Rust WebAssembly) or TinyGo; the standard Go runtime has a size floor of about 1.9 MB uncompressed

a network service on Wasm

not a good fit with the standard toolchain: no real sockets on preview 1; use a native build or a host with socket extensions

call C libraries

not possible from Wasm; see cgo and C Interoperability for native builds

Pitfalls

  • Mixing the wasm_exec.js of one release with a module built by another: copy the file from the exact toolchain that built the module, and treat both as a unit in the build.

  • await go.run(instance) before calling the module’s functions: run settles only when main returns, so awaiting it deadlocks a module that ends in select {}.

  • Letting main return in a module that registers callbacks: the program has exited and later calls fail.

  • Blocking inside a js.Func: it stalls the event loop; start a goroutine and return a Promise.

  • Forgetting Release() on short-lived functions: the table entry is not garbage-collected.

  • Passing a Go int, a slice, a map or a pointer-containing struct to //go:wasmimport or //go:wasmexport: the compiler refuses; pass a sized integer, a pointer and a length, or a structs.HostLayout struct.

  • Calling a reactor export before _initialize or wasi.initialize.

  • Holding a Uint8Array or DataView over memory.buffer across a call that can grow memory.

  • Expecting a file or a directory the host did not preopen, or os.TempDir() to exist.

  • Expecting net.Listen or net/http servers to be reachable from outside the module.

  • Running go vet ./... without GOOS/GOARCH: the Wasm-only files are silently skipped.

  • Relying on syscall/js stability across Go releases: it is exempt from the compatibility promise; isolate it behind one file.

  • Using node:wasi as a security boundary: the Node.js documentation says it is not one.

  • Expecting parallelism: GOMAXPROCS is 1 and a blocked host call blocks every goroutine.

Where the books are outdated

Neither of the two books this reference draws on covers WebAssembly: Bodner’s first edition predates GOOS=wasip1 by two years, and Vivien’s platform discussion lists the ports of the Go 1.6 era (no js/wasm either, since the port arrived in Go 1.11). What they cannot show, and what this page covers:

  • the js/wasm port (Go 1.11) and its support files, now in lib/wasm instead of misc/wasm (Go 1.24);

  • the wasip1/wasm port and //go:wasmimport (Go 1.21);

  • //go:wasmexport and WASI reactors with -buildmode=c-shared (Go 1.24), with the wider list of permitted parameter types for both directives;

  • structs.HostLayout (Go 1.23) for structs shared with a host;

  • the smaller initial memory (Go 1.24), the smaller heap increments (Go 1.26) and the unconditional use of sign-extension and saturating-conversion instructions (Go 1.26);

  • 128-bit Wasm SIMD in the experimental simd/archsimd package (Go 1.27);

  • the platform minimums that moved (Windows 10 or later from Go 1.21, macOS 13 or later from Go 1.27), against the older floors that the books mention.

The books' advice about GOOS and GOARCH as cross-compilation variables is still correct; only the table of targets is older.

Version notes

  • Go 1.11: the js/wasm port and syscall/js (experimental).

  • Go 1.21: GOOS=wasip1 (WASI preview 1) and //go:wasmimport.

  • Go 1.23: structs.HostLayout; the go_wasip1_wasm_exec script drops support for Wasmtime older than 14.0.0.

  • Go 1.24: //go:wasmexport; -buildmode=c-shared builds a WASI reactor; more parameter and result types for both directives; support files moved from misc/wasm to lib/wasm; a much smaller initial memory.

  • Go 1.26: the runtime manages Wasm heap memory in smaller chunks; sign extension and non-trapping float-to-integer instructions are always used (GOWASM signext and satconv are ignored).

  • Go 1.27: experimental 128-bit Wasm SIMD in simd/archsimd (GOEXPERIMENT=simd); no change to the two ports' interfaces. Examples were checked with go 1.27 in go.mod.

  • Not supported by the Go toolchain as of Go 1.27: WASI preview 2, the component model and GOOS=wasip2.