Across the Boundary
Numbers cross between JavaScript and WebAssembly as function arguments. Everything else, strings, arrays, structs, crosses through linear memory, and the design of that crossing decides much of how pleasant and how fast the integration between a compiled language and the browser is. This chapter covers the standard patterns: strings in both directions, bulk data handed straight to browser APIs, batching many calls into one, and calling back into WebAssembly from JavaScript.
#Strings out16.1
A string leaves WebAssembly as an address and a length, and JavaScript
decodes the bytes. This is the book host's print, and it is as simple as
it looks:
const decoder = new TextDecoder(); // UTF-8 by default
const print = (ptr, len) =>
console.log(decoder.decode(new Uint8Array(memory.buffer, ptr, len)));
Create the decoder once. Create the Uint8Array on every call, since a
cached one would be detached if memory had grown (Chapter 9). Both are
cheap: the view is a few fields, and decode copies the bytes once into
a JavaScript string.
If your language uses UTF-16 strings, as Java, C# and Dart do, decode
with new TextDecoder('utf-16le') over the same bytes, or use
String.fromCharCode on a Uint16Array, and the lengths are in code
units rather than bytes.
#Strings in16.2
Going the other way, JavaScript has to put the bytes somewhere in linear memory. JavaScript cannot allocate in WebAssembly's heap by itself, since the allocator is WebAssembly code, so the module exports its allocator and JavaScript calls it:
(module
(memory (export "memory") 1)
(global $heap (mut i32) (i32.const 1024))
(func (export "alloc") (param $n i32) (result i32)
(global.get $heap)
(global.set $heap (i32.add (global.get $heap) (local.get $n))))
;; count the bytes that start a UTF-8 code point, i.e. are not 10xxxxxx
(func (export "count_chars") (param $p i32) (param $n i32) (result i32)
(local $count i32)
(block $done
(loop $next
(br_if $done (i32.eqz (local.get $n)))
(if (i32.ne (i32.and (i32.load8_u (local.get $p)) (i32.const 0xc0)) (i32.const 0x80))
(then (local.set $count (i32.add (local.get $count) (i32.const 1)))))
(local.set $p (i32.add (local.get $p) (i32.const 1)))
(local.set $n (i32.sub (local.get $n) (i32.const 1)))
(br $next)))
(local.get $count)))
import fs from 'node:fs';
const { instance } = await WebAssembly.instantiate(fs.readFileSync('main.wasm'));
const { memory, alloc, count_chars } = instance.exports;
const encoder = new TextEncoder();
function passString(s) {
// UTF-8 needs at most 3 bytes per UTF-16 code unit
const max = s.length * 3;
const ptr = alloc(max);
const { written } = encoder.encodeInto(s, new Uint8Array(memory.buffer, ptr, max));
return [ptr, written];
}
for (const s of ['hello', 'naïve', '日本語', '🦀🦀']) {
const [ptr, len] = passString(s);
console.log(s, len, 'bytes,', count_chars(ptr, len), 'code points');
}
hello 5 bytes, 5 code points naïve 6 bytes, 5 code points 日本語 9 bytes, 3 code points 🦀🦀 8 bytes, 2 code points
encodeInto writes straight into memory without an intermediate copy. The
allocation is sized for the worst case, which wastes a little space but
avoids encoding twice. A real host would also free the string when the
module is done with it, which needs a free export and an agreement about
who owns the memory, the same agreement any C API needs.
#Bulk data without copying16.3
The best way across the boundary is not to cross it at all. A typed array over linear memory is a window onto the module's own bytes, and browser APIs that take typed arrays read them directly. A game can build its vertex data in linear memory and hand it to WebGL with no copying:
// WebAssembly fills `count` floats at `ptr`; JavaScript uploads them as they are
const vertices = new Float32Array(memory.buffer, ptr, count);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, vertices);
The same works for audio (AudioBuffer.copyToChannel), image data
(new ImageData(new Uint8ClampedArray(memory.buffer, ptr, w * h * 4), w,
h)), WebGPU's queue.writeBuffer, and anything else that accepts an
ArrayBufferView. The browser reads the bytes once, when it needs them.
The constraint is alignment: a Float32Array must start at a multiple of
4 bytes, so the data's address must be aligned, which your allocator
should guarantee.
#Batching: the command buffer16.4
Each call from WebAssembly to JavaScript costs something. In current engines the call itself is cheap, a few nanoseconds for an import with simple numeric parameters, but the JavaScript on the other side, and the browser API under that, often is not. A game that makes thousands of draw calls a frame, each with several arguments, spends much of its time at the boundary.
The usual fix is a command buffer: WebAssembly writes a compact
description of what it wants done into memory, and JavaScript reads and
executes the whole batch with one call per frame. Here is a tiny version,
in which each command is an opcode followed by its arguments, all i32s:
(module
(memory (export "memory") 1)
(global $cmd (mut i32) (i32.const 0))
(global $buffer i32 (i32.const 4096))
(func $emit (param $x i32)
(i32.store (i32.add (global.get $buffer) (global.get $cmd)) (local.get $x))
(global.set $cmd (i32.add (global.get $cmd) (i32.const 4))))
;; opcode 1: clear(colour); opcode 2: rect(x, y, w, h)
(func $clear (param $colour i32)
(call $emit (i32.const 1)) (call $emit (local.get $colour)))
(func $rect (param $x i32) (param $y i32) (param $w i32) (param $h i32)
(call $emit (i32.const 2))
(call $emit (local.get $x)) (call $emit (local.get $y))
(call $emit (local.get $w)) (call $emit (local.get $h)))
;; build one frame's commands; return where they are and how many bytes
(func (export "frame") (param $t i32) (result i32 i32)
(local $i i32)
(global.set $cmd (i32.const 0))
(call $clear (i32.const 0x202040))
(loop $each
(call $rect
(i32.add (i32.mul (local.get $i) (i32.const 40)) (local.get $t))
(i32.const 10) (i32.const 32) (i32.const 32))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br_if $each (i32.lt_u (local.get $i) (i32.const 3))))
(global.get $buffer)
(global.get $cmd)))
import fs from 'node:fs';
const { instance } = await WebAssembly.instantiate(fs.readFileSync('main.wasm'));
const { memory, frame } = instance.exports;
// stands in for a canvas: a real host would call ctx.fillRect and so on
function execute(ptr, bytes) {
const words = new Int32Array(memory.buffer, ptr, bytes / 4);
for (let i = 0; i < words.length;) {
switch (words[i++]) {
case 1: console.log(`clear #${words[i++].toString(16)}`); break;
case 2: console.log(`rect ${words[i++]},${words[i++]} ${words[i++]}x${words[i++]}`); break;
default: throw new Error('bad command');
}
}
}
const [ptr, bytes] = frame(5);
execute(ptr, bytes);
clear #202040 rect 5,10 32x32 rect 45,10 32x32 rect 85,10 32x32
One call into WebAssembly produced four commands, executed by one JavaScript loop. The format can be anything both sides agree on. Real engines use the same idea at larger scale: Emscripten's WebGL bindings can proxy calls through a command buffer to a worker, and WebGPU's own design is built around recording commands into buffers.
A command buffer also makes the module easier to test: the commands are data, so a test can run a frame and compare the buffer with what it expects, without a browser.
#Calling back into WebAssembly16.5
Some browser APIs call you: animation frames, input events, audio
processing. The JavaScript side receives the call and passes it on to an
export. When the module needs to choose which of its functions gets
called, it can pass a table index, or with reference types, export a
table and let JavaScript call table.get(i):
(module
(import "env" "on_key" (func $on_key (param i32 i32))) ;; key, callback index
(import "env" "print_i32" (func $print (param i32)))
(table (export "callbacks") 2 funcref)
(elem (i32.const 0) $jump $fire)
(func $jump (param $key i32) (call $print (i32.add (i32.const 1000) (local.get $key))))
(func $fire (param $key i32) (call $print (i32.add (i32.const 2000) (local.get $key))))
(func (export "_initialize")
(call $on_key (i32.const 32) (i32.const 0)) ;; space: jump
(call $on_key (i32.const 70) (i32.const 1)))) ;; F: fire
import fs from 'node:fs';
const handlers = new Map();
const env = {
on_key: (key, index) => handlers.set(key, index),
print_i32: (x) => console.log(x),
};
const { instance } = await WebAssembly.instantiate(fs.readFileSync('main.wasm'), { env });
const { callbacks, _initialize } = instance.exports;
_initialize();
// a pretend keyboard: in a browser this would be addEventListener('keydown', ...)
for (const key of [70, 32, 70]) callbacks.get(handlers.get(key))(key);
2070 1032 2070
#Designing the interface16.6
The boundary between a compiled language and its host is an API like any other, and the same advice applies. A few points are specific to WebAssembly:
- Prefer numbers.
i32andf64parameters cross most cheaply.i64s become BigInts, which allocate. Pass several scalars rather than a pointer to a struct when there are only a few. - Prefer few calls with much data to many calls with little. Command buffers, and typed arrays over memory, beat per-item calls.
- Make ownership explicit. Every pointer that crosses needs a rule saying who frees it and when. The simplest rule is that memory passed in a call belongs to the caller and is valid only during the call.
- Generate the glue. Hand-written glue code drifts out of sync with
the module. Tools like Rust's
wasm-bindgenand Emscripten'sembindgenerate both sides from one description. For your own language, generating the JavaScript glue from the same declarations that produce the imports is worth the effort as soon as the interface has more than a handful of functions.
#What you have seen16.7
Strings cross as bytes in memory: decoded with TextDecoder on the way
out, encoded with encodeInto into memory the module allocated on the way
in. Bulk data is best not copied at all, but handed to browser APIs as
typed arrays over memory. Many small calls can be batched into a command
buffer, executed once per frame. Callbacks reach the right function
through a table.
The next chapter covers what happens when things go wrong.