How to Use Go Wasm with WASI (WebAssembly System Interface)

Web
Go does not support WASI with the js/wasm target; use GOOS=wasip1 GOARCH=wasm to compile for WASI instead.

The browser isn't the only place for WebAssembly

You wrote a Go program that processes images, crunches numbers, or parses logs. You want to run it inside a serverless function, a custom sandbox, or a CLI tool that shares a single binary across platforms. You compile it for WebAssembly, drop it into a runtime, and get a panic. The runtime expects system calls. Your binary only knows how to talk to JavaScript.

The mismatch happens because Go ships with two completely different WebAssembly targets. One targets the browser. The other targets system runtimes. They share the .wasm extension but speak different languages under the hood. Picking the wrong one breaks your program before it even starts.

What WASI actually does

WebAssembly System Interface, or WASI, is a standardized set of system calls for WebAssembly modules. Think of it like a universal power adapter. Instead of wiring your code directly to a specific operating system kernel or a browser's DOM, WASI gives you a safe, predictable way to ask for files, network access, time, and environment variables. The host runtime decides what to actually allow. The module just asks.

Go's js/wasm target generates code that expects a JavaScript host. It replaces traditional system calls with JavaScript function calls. It expects window, document, and a browser event loop. Go's wasm/wasi target generates code that expects a WASI host. It emits standard WASI syscalls. It expects a runtime like Wasmtime, Wazero, or Wasmer. The two targets are mutually exclusive. You choose one at compile time, and the Go toolchain swaps out the entire standard library implementation to match.

Convention aside: Go uses wasip1 as the GOOS value, not wasi. The p1 stands for WASI Preview 1, the current stable ABI. The naming stays explicit so you never accidentally mix preview versions with future standards.

WASI is not a browser. It is a sandboxed system interface.

The minimal build

Here is the simplest program that proves the target works. It reads an environment variable and prints to standard output.

package main

import (
	"fmt"
	"os"
)

// main initializes the program and prints the runtime environment.
func main() {
	// os.Getenv pulls from the WASI host's environment map.
	// The host injects these values before the module starts.
	name := os.Getenv("USER")
	if name == "" {
		name = "WASI Guest"
	}
	// fmt.Println writes to the WASI stdout file descriptor.
	// The host runtime captures and displays the output.
	fmt.Printf("Hello from %s on WASI\n", name)
}

Compile it with the correct environment variables. The GOOS and GOARCH flags tell the compiler which ABI to target.

GOOS=wasip1 GOARCH=wasm go build -o main.wasm main.go

Run the output with any WASI-compatible runtime. Wasmtime is the most common choice for development.

wasmtime run --env USER=Developer main.wasm

The module starts, asks the host for the USER variable, prints the greeting, and exits. No JavaScript glue code is involved. No browser event loop is required.

Walking through the compilation

When you pass GOOS=wasip1 GOARCH=wasm to the Go toolchain, the compiler switches its standard library backend. It compiles os, net, syscall, and time against the WASI ABI instead of the browser interop layer. The output is a .wasm file containing WebAssembly bytecode and a custom section that declares WASI imports. The file is self-contained and portable across any runtime that implements the Preview 1 specification.

The runtime loads the module, resolves the import table, and injects the required system functions. When your code calls os.Open, the Go runtime translates that into a WASI path_open syscall. The host runtime receives the call, checks its sandbox policy, and either returns a file descriptor or an error. The cycle repeats for every system interaction.

This architecture keeps the module small and predictable. The binary does not bundle a C standard library or a JavaScript engine. It only contains your code and the Go runtime's WASI shims. The host provides the rest.

A realistic example: reading a file

Real programs rarely just print greetings. They read configuration, process data, or write results. Here is a program that reads a text file passed as a command-line argument and counts its lines.

package main

import (
	"bufio"
	"fmt"
	"os"
)

// main reads a file and counts its lines using WASI file I/O.
func main() {
	// os.Args contains the module name and any host-provided arguments.
	// WASI passes these through the argv import table.
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: program <file>")
		os.Exit(1)
	}
	// os.Open translates to a WASI path_open syscall.
	// The host resolves the path relative to its mounted directory.
	file, err := os.Open(os.Args[1])
	if err != nil {
		// os.Exit calls the WASI proc_exit syscall.
		// The host terminates the module with the given code.
		fmt.Fprintf(os.Stderr, "failed to open: %v\n", err)
		os.Exit(1)
	}
	// Defer ensures the file descriptor is released to the host.
	// WASI tracks open files strictly to prevent leaks.
	defer file.Close()

	// bufio.Scanner reads chunks from the WASI fd_read syscall.
	// The host supplies data in 64KB blocks by default.
	scanner := bufio.NewScanner(file)
	count := 0
	for scanner.Scan() {
		count++
	}
	// Handle any I/O error that occurred during scanning.
	// WASI returns specific errno values for permission or EOF.
	if err := scanner.Err(); err != nil {
		fmt.Fprintf(os.Stderr, "read error: %v\n", err)
		os.Exit(1)
	}
	fmt.Printf("lines: %d\n", count)
}

Compile it the same way. Run it with a runtime that mounts a directory so the module can see the file.

GOOS=wasip1 GOARCH=wasm go build -o counter.wasm counter.go
wasmtime run --dir=. counter.wasm config.txt

The --dir=. flag tells Wasmtime to mount the current directory at the module's root. Without it, the host blocks the path access for security. WASI defaults to deny. You must explicitly grant permissions.

Pitfalls and compiler reality

The most common mistake is compiling with the wrong target. If you forget GOOS=wasip1, Go defaults to your host OS or js if you previously set it. The compiler will happily produce a binary that panics immediately when you try to run it in a WASI runtime. The runtime cannot resolve the JavaScript interop imports, and the module fails to instantiate.

If you accidentally mix targets, the compiler rejects the program with build constraints exclude all Go files in directory or undefined: js.Global when you try to use browser-specific packages. The Go toolchain enforces target separation at the package level. You cannot import syscall/js in a wasip1 build. The compiler catches it early.

Standard library support in WASI is still maturing. Some net package functions work, but full TCP/UDP socket support depends on the host runtime's implementation. DNS resolution may fall back to host-provided resolvers. If you try to use an unsupported feature, you get a runtime error like operation not supported from the os package. The Go team is actively expanding WASI coverage, but you should test your standard library usage against your target runtime before shipping.

Error handling follows the usual Go pattern. Check err != nil, wrap it with fmt.Errorf, and return it. The verbosity is intentional. It makes the failure path visible in code that runs in untrusted environments.

WASI is a contract. The host enforces it. Your code must respect the sandbox.

When to pick which target

Use js/wasm when you need to interact with a browser's DOM, manipulate canvas elements, or run code inside a webpage alongside JavaScript libraries. Use wasm/wasi when you need file I/O, environment variables, network access, or a standalone sandboxed runtime outside the browser. Use native Go compilation when you are deploying to a traditional server, container, or bare metal machine where system calls are already available and performance matters most. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Where to go next