Go: Under the Hood Under the Hood
中文

19.4 Rendering in the Browser

The rendering of the first three sections either crosses the FFI boundary to the local GPU (19.1, 19.2) or stays on the CPU as software rendering (19.3). This section changes the environment to the browser. A Go program can be compiled to WebAssembly (Wasm) and run in the browser, so rendering has to cross a new boundary: the one between Wasm and JavaScript. The cost structure of this boundary and the ways of dealing with it are essentially those of the FFI boundary discussed in Chapter 18. The difference is that the Go runtime in the browser has only one thread, so of the two layers of parallelism in 19.3 only one remains.

19.4.1 Go Enters the Browser: WebAssembly and syscall/js

Since Go 1.11, setting GOOS=js GOARCH=wasm compiles a program into a .wasm module. A web page loads and starts the module through the wasm_exec.js shipped with the Go distribution; the Go Wiki specifically warns that wasm_exec.js must come from the same Go release (the same 1.N) as the one used to compile the module [Go Authors, n.d.a].

A Wasm module runs in a sandbox and can only compute; the DOM, canvas and GPU all belong to the browser’s JavaScript environment. Go code reaches them through the standard library’s syscall/js: js.Global() returns the JavaScript global object, and the Get, Set and Call methods of js.Value read and write JavaScript properties and call JavaScript functions [Go Authors, n.d.b]:

1
2
3
4
doc := js.Global().Get("document")
canvas := doc.Call("getElementById", "screen")
ctx := canvas.Call("getContext", "2d")
// every Get and Call enters JavaScript from Wasm and returns

The implementation of syscall/js is direct: these operations are import functions declared with //go:wasmimport gojs ... and provided by wasm_exec.js. A JavaScript value itself cannot be passed into Wasm; the js.Value held on the Go side is only a 64-bit reference. Numbers other than 0 and NaN are encoded directly as IEEE 754 values, and every other value is encoded as a NaN whose low 32 bits are an index into the object table on the JavaScript side [Go Authors, 2026a]. Strings and arguments must be converted between the two sides on every call.

So syscall/js is to Wasm and JavaScript what cgo is to Go and C: another FFI boundary, with a fixed cost for every crossing. The conclusion of 18.1 applies here as well: cross as few times as possible.

Go’s Wasm support is not limited to the browser. Go 1.21 added GOOS=wasip1, targeting the WASI preview 1 system-call interface, and Go 1.24 added the go:wasmexport directive, which exports Go functions for a Wasm host to call [Mui, 2025]. wasip1 runs outside the browser and does not provide syscall/js; this section discusses only js/wasm in the browser.

19.4.2 Where the Rendering Boundary Lies

Go code cannot access the canvas directly. Whether it is Canvas 2D, WebGL or WebGPU, Go must in the end call the browser’s graphics API through js.Value. The rendering boundary in the browser therefore lies at syscall/js.

This immediately brings the same requirement as reducing draw calls in 19.1. The naive way to draw is to call ctx.Call("fillRect", x, y, 1, 1) once per pixel, so every pixel crosses the boundary once; with hundreds of thousands or millions of pixels per frame, that is as many crossings. This is exactly the anti-pattern of crossing the boundary repeatedly in a tight loop described in 18.1.

The correct approach is again to do more work per crossing. The most typical way is to bring in the software rendering of 19.3: compute the whole frame of pixels in pure Go inside Wasm, then hand the whole frame to the canvas with one bulk copy.

1
2
3
4
5
6
7
8
// render the whole frame inside Go (no boundary crossing), then submit it at once (two or three crossings)
img := image.NewNRGBA(image.Rect(0, 0, w, h)) // non-premultiplied RGBA, 4 bytes per pixel
imgData := ctx.Call("createImageData", w, h)
data := imgData.Get("data") // Uint8ClampedArray of length w*h*4

renderFrame(img)                       // pure Go computation, see 19.3
js.CopyBytesToJS(data, img.Pix)        // one bulk copy
ctx.Call("putImageData", imgData, 0, 0) // one submission

The data of an ImageData is a Uint8ClampedArray in RGBA order [MDN, n.d.a]. js.CopyBytesToJS requires its destination to be a Uint8Array or Uint8ClampedArray and panics otherwise, and the number of bytes copied is the smaller of the two lengths [Go Authors, n.d.b]. For an image created by image.NewNRGBA, Pix is stored contiguously by row with length exactly w*h*4, matching the layout of ImageData. NRGBA is chosen rather than RGBA because color.RGBA represents colors with premultiplied alpha, while color.NRGBA is non-premultiplied [Go Authors, 2026a]. The HTML specification points out that the canvas stores pixels internally with premultiplied alpha, and pixels that are not fully opaque are converted when written through putImageData [WHATWG, n.d.], which shows that the data in ImageData is non-premultiplied. If all pixels are fully opaque, the two representations are identical.

This compresses hundreds of thousands of crossings per frame into two or three. The software rendering of 19.3 is thus more than a fallback for machines without a GPU here: it is a common way to avoid the syscall/js boundary cost, with all the computation kept on the Go side of the boundary and a single submission at the end.

19.4.3 The GPU in the Browser: WebGL and WebGPU

When GPU acceleration is needed, the browser offers WebGL and WebGPU. WebGL 1.0 is derived from OpenGL ES 2.0 [Khronos, n.d.]. Unlike native OpenGL, js/wasm has only one thread, so the thread-affinity problem of 19.2 does not arise here. The WebGPU specification states explicitly that it is unrelated to WebGL, and its API was designed from the start to map efficiently onto native GPU APIs from 2014 onward [W3C, n.d.a], that is, the explicit APIs of 19.1.3. From the Go side, both are JavaScript APIs called through syscall/js.

The concepts of WebGPU correspond one to one with the content of Chapter 18 and 19.1:

  • A GPUBuffer is backed by GPU memory and must be created and written explicitly. When no longer needed it can be released early with destroy() instead of waiting for garbage collection, which corresponds to the memory divide discussed in 18.3.
  • A command encoder records a sequence of commands into a GPUCommandBuffer, which is submitted in bulk through GPUQueue.submit(); this corresponds to the command buffers of 19.1.3 and the batched submission of 18.1.
  • A compute pipeline runs compute shaders and can do general-purpose GPU computing, which corresponds to the GPGPU of Chapter 18; shaders are written in WGSL [W3C, n.d.b].

GPU rendering in the browser therefore crosses two boundaries: Go first enters JavaScript through syscall/js, and JavaScript then enters the GPU through the browser’s WebGPU implementation. Command buffers merge the submissions on the second boundary; on the first boundary, every encoded command (set the pipeline, bind resources, draw) is still one js.Value call. To reduce crossings of the first boundary as well, record the draw commands that do not change from frame to frame into a render bundle, GPURenderBundle, and call executeBundles once per frame [W3C, n.d.a], and write the data that changes every frame into a few buffers in one place. Both boundaries obey the same principle: reduce the number of crossings and merge work into a few calls.

19.4.4 The Single-Threaded js/wasm

The outer layer of parallelism in 19.3 depends on multiple cores. In js/wasm in the browser, this layer does not exist. The runtime source says so: the comment at the top of lock_js.go reads “js/wasm has no support for threads yet. There is no preemption.”; newosproc, which creates new threads, in os_wasm.go simply throws “not implemented”, and getCPUCount always returns 1; haveSysmon is defined as GOARCH != "wasm", so no sysmon is started on Wasm [Go Authors, 2026a]. The following program runs as js/wasm on this book’s pages:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package main

import (
	"fmt"
	"runtime"
)

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

Compiled to js/wasm with Go 1.26.10 and run under Node.js, the output is:

1
2
3
js wasm
NumCPU: 1
GOMAXPROCS: 1

The runtime can still schedule thousands of goroutines, but they all take turns on one thread: this is concurrency, not parallelism. The structure of 19.3 in which N cores each render one band of rows degenerates here into one core rendering all the bands in turn. The inner layer, SIMD, is still available: Go 1.27’s simd/archsimd adds support for WebAssembly’s 128-bit SIMD, and the portable simd package also uses 128-bit vectors on wasm [Go Authors, 2026b], though both are still experimental.

The absence of preemption brings another constraint. Go code runs on the browser’s JavaScript thread (the main thread in the main page), and the runtime returns control to the JavaScript event loop only when all goroutines are waiting [Go Authors, 2026a]. If the computation of one frame lasts hundreds of milliseconds, the page can neither respond to input nor repaint during that time. The documentation of js.FuncOf also warns that calling a wrapped Go function from JavaScript pauses the event loop and starts a new goroutine, and if the function blocks, the event loop stays blocked until it returns; a function that needs to block should explicitly start a new goroutine [Go Authors, n.d.b]. The frame loop in the browser is therefore usually driven by requestAnimationFrame: the browser calls the callback before the next repaint [MDN, n.d.c], the Go code in the callback renders one frame and returns, and registers the callback again for the next frame. Each frame’s computation must finish within the frame’s time budget.

Real parallelism in the browser can only be arranged outside the Go runtime. A Web Worker runs a script in a background thread separate from the main thread [MDN, n.d.b]. Each Worker can load its own Go Wasm instance, compute a part of the pixels, and aggregate the results through message passing. To share memory, SharedArrayBuffer can be used, but it requires the page to be in a secure context with cross-origin isolation enabled, that is, the server must return the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy response headers [MDN, n.d.d]. Each instance has its own runtime and heap, and goroutines cannot be scheduled across instances, so this parallelism is closer to multiple processes. The Wasm-level threads proposal (shared memory and atomic operations) is currently at phase 4 [WebAssembly CG, n.d.], while Go issue #28631, which asks for thread support for Wasm, is still open and labeled Unplanned [Go Authors, 2018]. In the browser, the experience that “spawning more goroutines yields parallelism” does not hold.

Summary

When Go moves into the browser, rendering has to cross a new FFI boundary: syscall/js between Wasm and JavaScript. It has the same shape as cgo, and the principles of 18.1 still apply: cross fewer times and merge submissions. The most typical practice is to compute the whole frame with the software rendering of 19.3 inside Wasm and then submit it once with CopyBytesToJS and putImageData, which makes software rendering a common way to avoid the boundary cost. When GPU acceleration is needed, WebGL or WebGPU can be used. WebGPU brings the concepts of GPU memory, command buffers and compute shaders into the browser but stacks two boundaries, and render bundles can reduce the crossings of both. The js/wasm runtime has a single thread and no preemption: the outer parallelism of 19.3 is gone, the inner SIMD remains usable, long computations block the event loop, the frame loop should be driven by requestAnimationFrame, and real parallelism can only be arranged outside the runtime with multiple Web Workers.

This chapter covered three destinations for graphics: the local GPU, the local CPU and the browser. They differ in form, but the basis for choosing among them is the same: the cost of crossing a boundary. Chapter 20 turns to AI inference and serving; when Go connects to a local large-model runtime, it likewise has to cross the FFI boundary discussed in Chapter 18.

Further Reading