Chapter 16

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:

#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.