An Introduction to WebAssembly for JavaScript Developers

April 21, 2021

Pascal Pares

Summary

Revision history:

Introduction

WebAssembly is a standard of the World Wide Web consortium, which latest official release is WebAssembly Core Specification, W3C Recommendation, 5 December 2019. It is now supported by a majority of the main browsers. The primary purpose of this standard is to enable interoperability of JavaScript code executed by the Web browser with a compiled binary code. The WebAssembly modules are mainly dedicated to the implemention of algorithms requiring a fast execution compared to the capabilities of the JavaScript virtual machine. These algorithms are the basis of interactive 3D visualization, audio and video softwares, as well as games.

For example, when running the Google Earth web site, you can discover that your web browser downloads this WebAssembly module:

https://earth.google.com/static/9.134.0.0/earthwasm.wasm

This paper is an introduction of the JavaScript WebAssembly interface. It presents how JavaScript code interacts with WebAssembly modules, it can be also of interest for those who mind to understand the implementation of wrappers such as the ones generated by emscripten.

This paper is based on the specification of WebAssembly JavaScript Interface, W3C Recommendation, 5 December 2019.

All the examples are tested using Node.js release v14.16.0. These examples can be easily adapted to run on a Web Browser. The source code of the examples is available here. You will find for each example:

Instantiation

To run a WebAssembly module, we must load the bytecode of the module stored in a .wasm file. From Node.js you can load it with the fs module:

const fs = require("fs");
let bytecode = fs.readFileSync('add/add.wasm');

Alternatively, you can request the file over HTTP:

let bytecode = await fetch("add/add.wasm");

The bytecode is an intermediate representation of a program. It could be executed by a virtual machine, but the purpose of WebAssembly is to compile this bytecode into a binary code for the host machine such as a C language program.

The instantiation step compiles the code, and initializes the internal memory of the WebAssembly module:

let wasm = await WebAssembly.instantiateStreaming(bytecode);

Warning: For the Web Browsers (Edge, Chrome, Firefox), we are using the instantiateStreaming function, whereas for Node.js, we are using the instantiate function, because the former one is not yet supported by Node.js v14.16.0.

After the instantiation, we can call any exported function:

let run = async () => {
    try {
        let bytecode = await fetch("add/add.wasm");
        let wasm = await WebAssembly.instantiateStreaming(bytecode);
        console.log(wasm.instance.exports.addInt32(1,2));
    }   
    catch(e) {  
        console.error(e);
    }
};

> run().then();
> 3

Source code

If you need to compile the bytecode without instantiation, in order to transfer the module to a JavaScript worker then you will use the WebAssembly.compile function and you will instantiate the WebAssembly module from the worker with the WebAssembly.Instance constructor:

let module = WebAssembly.compile(bytecode);
let wasm = new WebAssembly.Instance(module);

Parameter Types

The WebAssembly module accepts the following types for the parameters, only JavaScript numbers can be set as parameter values:

WebAssembly TypeNaming conventiontypeof()
unsigned/signed integer on 32 bitsint32"number"
unsigned/signed integer on 64 bitsn/an/a
float on 32 bitsfloat32"number"
float on 64 bitsfloat64"number"

If you transmit a number whereas an integer encoded on 64 bits is expected you will get an exception:

let run = async () => {
    try {
        let bytecode = await fetch("add/add.wasm");
        let wasm = await WebAssembly.instantiateStreaming(bytecode);
        console.log(wasm.instance.exports.addInt64(1,2)); 
    }   
    catch(e) {  
        console.error(e);
    }
};

> run().then();
TypeError: wasm function signature contains illegal type

Callbacks

The WebAssembly module can call a JavaScript function. From the point of view of the module this is an imported (or extern) function. For example, the WebAssembly echo module implementation would be in C programming language:

extern void printNumber(int);
void echo(int n) { printNumber(n); }

The echo function just makes a call to the printNumber JavaScript function. When we instantiate the WebAssembly module, we must set the imported printNumber function, to enable a dynamic linking between the browser and the WebAssembly module:

let run = async () => {
    try {
        let bytecode = await fetch("echo/echo.wasm");
        let imports = {
            env: {
                printNumber: (arg) => { console.log(arg); }
            }
        };    
        
        let wasm = await WebAssembly.instantiateStreaming(bytecode, imports);
        wasm.instance.exports.echo(2021); 
    }   
    catch(e) {  
        console.error(e);
    }
 };
> run().then();
2021

Source code

Auto Description

Two built-in functions allows to inspect the interface of a WebAssembly module, the first one describes the imports items and the second one describes the exports items:

let run = async () => {
    try {
        let bytecode = await fetch("echo/echo.wasm");
        let imports = {
            env: {
                printNumber: (arg) => { console.log(arg); }
            }
        };    
        
        let wasm = await WebAssembly.instantiateStreaming(bytecode, imports);
        console.log(WebAssembly.Module.imports(wasm.module)); 
        console.log(WebAssembly.Module.exports(wasm.module));
    }   
    catch(e) {  
        console.error(e);
    }
 };
> run().then();
[ { module: 'env', name: 'printNumber', kind: 'function' } ]
[ { name: 'echo', kind: 'function' } ]

However these functions will give neither the signature of the functions nor the documentation of elements.

Dynamic Linking

When several WebAssembly modules are required, they may depend from each other. Let's suppose the following design:

The JavaScript code must ensure that the WebAssembly module math is instantiated before the WebAssembly module app in order to import the required function sum.

let bytecodeLib = await fetch("math.wasm");
let bytecodeApp = await fetch("app.wasm");
let wasmLib = await WebAssembly.instantiateStreaming(bytecodeLib);
let imports = { math: { sum: wasmLib.instance.exports.sum } };
let wasmApp = await WebAssembly.instantiateStreaming(bytecodeApp, imports);
// app is ready.
wasmApp.instance.exports.run();

By convention, the key of the imports section is the module name: math.

In our examples, when the imports come from the top level JavaScript module we are using the env key.

Auto Start

You must be aware that the WebAssembly module may have defined an internal start function. This function will automatically start a processing as soon as the WebAssembly module is instantiated.

Global Variables

Some global variables can be shared between the JavaScript code and the WebAssembly module. They can be defined either on JavaScript side or on WebAssembly side. Like parameters, a global variable can only be typed as a JavaScript number.

Let's create a mutable global variable as an integer encoded on 32 bits, with the initial value of 0:

let counter = new WebAssembly.Global( { value:'i32', mutable:true }, 0);

Now we can set this global variable at instantiation time, so that the inc function will increment it:

let run = async () => {
    try {
        let bytecode = await fetch("counter/counter.wasm");
        let imports =  { env: { "counter": counter } };
        let wasm = await WebAssembly.instantiateStreaming(bytecode, imports);
        wasm.instance.exports.inc(); 
        wasm.instance.exports.inc();         
        console.log("Counter value is", counter.value); 
    }   
    catch(e) {  
        console.error(e);
    }
 };

> run().then();
Counter value is 2

Source code

Memory Buffer

The memory buffer (or linear memory in WebAssembly terminology) is a buffer of bytes, typed as an ArrayBuffer for JavaScript. A single memory buffer is available to store all the data shared between the WebAssembly module and the JavaScript code.

Allocation

A memory buffer can be allocated on JavaScript side, or the WebAssembly module side. On JavaScript side, the memory is allocated by specifying an initial size that is a number of pages, each page size is 64 kilo-bytes. A maximum size can be specified optionally as well:

let memory = new WebAssembly.Memory( { initial: 1, maximum: 2 } );

Once the memory is allocated, we can initialize the content. We need to access the buffer property by creating an "array view". For example, to store integers encoded on 32 bits, we use the Uint32Array array view:

// Wrap the memory.buffer as an array of Unsigned Integers 
let numbers = new Uint32Array(memory.buffer);

Now we can set the numbers in the memory buffer:

for (let i = 0; i < 10; i++) {
  numbers[i] = i;
}

As a use case of the memory buffer we have just allocated, we call the sum WebAssembly module which interface is in C programming language:

extern int mem[]; // imported memory buffer
int sum (int len); // sum the "len" integers stored in mem and return the result

We transmit this buffer to the WebAssembly Module at instantiation time.

Here we suppose that the expected memory name is mem and is imported via the env key.

Then we call the sum functions with the size of the array to sum:

let run = async () => {
    try {
        let bytecode = await fetch("sum/sum.wasm");
        let imports = { env: { mem: memory } };
        let wasm = await WebAssembly.instantiateStreaming(bytecode, imports);
        let sum = wasm.instance.exports.sum(10);
        console.log(sum); 
    }   
    catch(e) {  
        console.error(e);
    }
 };
> run().then();
45

Source code

Strings

As the parameters of WebAssembly functions are only numbers, a string value cannot be used as a parameter. To transmit a string, we must move the characters into the memory buffer. We use the TextEncoder function to do that in an efficient and a flexible way. However we cannot avoid a memory copy:

const hello = "hello world!";
let memory = new WebAssembly.Memory( { initial:1 } );
let buffer = new Uint8Array(memory.buffer, 0, hello.length); // No copy
let encoder = new TextEncoder();
encoder.encodeInto(hello, buffer); // Make a copy

Now we can call a reverse function to reverse the bytes order. The WebAssembly module interface is in C programming language:

extern unsigned char mem[]; // imported memory buffer
void reverse (int len); // reverse the bytes order for len bytes

Note that the reverse function needs a convention to locate the end of the string. It could be a length stored in the first bytes of the buffer, a zero byte at the end of the data. Here we specify the length of the input string as a parameter. At last we rebuild the string result from the memory buffer using the TextDecoder class, which will make another copy of the bytes:

let run = async () => {
    try {
        let bytecode = await fetch("reverse/reverse.wasm");
        let imports = { env: { mem: memory } };
        let wasm = await WebAssembly.instantiateStreaming(bytecode, imports);
        wasm.instance.exports.reverse(hello.length);
       
        let decoder = new TextDecoder();
        let reverseString = decoder.decode(buffer); // Make a copy
        console.log(reverseString);
    }   
    catch(e) {  
        console.error(e);
    }
 };
> run().then();   
!dlrow olleh 

Source code

Shared Memory Buffer

The memory buffer can be shared between the JavaScript code and the WebAssembly module, but we can also share the memory buffer between WebAssembly modules.

We can make a demonstration by instantiating twice the reverse WebAssembly module with the same memory buffer. We create a new WebAssembly instance module from a first one, and we set the same memory buffer:

let imports = { env: { mem: memory } };
let wasm1 = await WebAssembly.instantiateStreaming(bytecode, imports);
let wasm2Instance = new WebAssembly.Instance(wasm1.module, imports);

Then let's do a reverse on the share memory buffer two times by the two distinct WebAssembly modules:

let run = async () => {
    try {
        let bytecode = await fetch("reverse/reverse.wasm");
        let imports = { env: { mem: memory } };
        let wasm1 = await WebAssembly.instantiateStreaming(bytecode, imports);
        let wasm2Instance = new WebAssembly.Instance(wasm1.module, imports);
       
        let decoder = new TextDecoder();
        wasm1.instance.exports.reverse(hello.length);       
        let reverseString = decoder.decode(buffer); // Make a copy
        console.log("1", reverseString);
    
        wasm2Instance.exports.reverse(hello.length);
        reverseString = decoder.decode(buffer); // Make a copy
        console.log("2", reverseString);
    }   
    catch(e) {  
        console.error(e);
    }
 };
> run().then();   
1 !dlrow olleh
2 hello world!

Source code

Static Memory Buffer

As the memory buffer is unique, we have to store all shared data in the same memory buffer. For example, a WebAssembly module may allocate three arrays specified as follow in C programming language:

int a[3] = { 1, 2, 3 };
char b[6] = { 'A', 'B', 'C', 'D', 'E', 'F' };
int c[2] = { 4, 5 };

In this case, the JavaScript code does not define the memory buffer but gets it as an exported property:

let memory = wasm.instance.exports.memory;

The memory buffer is organized as follow.

   Offsets|       Bytes       | Array Entries
----------+-------------------+----------------------
        0 |0x01 0x00 0x00 0x00| a[0]
        4 |0x02 0x00 0x00 0x00| a[1]
        8 |0x03 0x00 0x00 0x00| a[2] 
       12 |0x41 0x42 0x43 0x44| b[0] b[1] b[2] b[3]
       16 |0x45 0x46 0x00 0x00| b[4] b[5] 0x00 0x00
       20 |0x04 0x00 0x00 0x00| c[0]
       24 |0x05 0x00 0x00 0x00| c[1]

From JavaScript we must take into account these offsets to map these arrays on the correct bytes. The Uint32Array constructor allows to access these data without making a copy from an array to another:

let run = async () => {
    try {
        let bytecode = await fetch("offset/offset.wasm");
        let wasm = await WebAssembly.instantiateStreaming(bytecode);
        let memory = await wasm.instance.exports.memory;
        let a = new Uint32Array(memory.buffer, 0, 3);
        let b = new Uint8Array(memory.buffer, 12, 6);
        let c = new Uint32Array(memory.buffer, 20, 2);

        console.log(a);
        console.log(new TextDecoder().decode(b));
        console.log(c);
    }
    catch (e) {
        console.error(e);
    }
};

> run().then();
Uint32Array(3) [ 1, 2, 3 ]
ABCDEF
Uint32Array(2) [ 4, 5 ]

Source code

This mapping code may come from a JavaScript wrapper distributed with the WebAssembly module.

Dynamic Memory Buffer

Another approach is to consider the memory buffer as a heap upon which one can implement a memory allocator similar to the malloc function of the C programming language.

As an example, we implement a simple memory allocator on the JavaScript side. The MemoryAllocator class stores the offset of the next available byte in the memory buffer, and maps an array on the memory buffer according to the input length and the current offset. When we allocate an array of integers, we make sure that the offset is aligned:

class MemoryAllocator {
  
    constructor(buffer) {
        this.buffer = buffer;
        this.offset = 0;
    }
    
    allocUint32Array (len) {
        // Align the offset on 32 bits integers
        let int32Offset = Math.ceil(this.offset / Uint32Array.BYTES_PER_ELEMENT); 

        let beginOffset = int32Offset * Uint32Array.BYTES_PER_ELEMENT;
        let endOffset = (int32Offset + len) * Uint32Array.BYTES_PER_ELEMENT;

        let subArray = new Uint32Array(this.buffer, beginOffset, len);
        this.offset = endOffset;
        return subArray.fill(0);
    }

    allocUint8Array (len) {
        let subArray = new Uint8Array(this.buffer, this.offset, len);
        this.offset += len;
        return subArray.fill(0);
    }
}

Source code

Now we can allocate each array upon the heap:

let run = () => {
  let memory = new WebAssembly.Memory( { initial: 1 } );
            
  let allocator = new MemoryAllocator(memory.buffer);   

  // Allocate and initialize: int a[3] = { 1, 2, 3 };
  let a = allocator.allocUint32Array(3);
  a.set([1,2,3]);

  // Allocate and initialize: char b[6] = { 'A', 'B', 'C', 'D', 'E', 'F' };
  let b = allocator.allocUint8Array(6);
  new TextEncoder().encodeInto("ABCDEF", b);
            
  // Allocate and initialize: int c[2] = { 4, 5 };
  let c = allocator.allocUint32Array(2);
  c.set([4, 5]);
              
  console.log(a);
  console.log(b, new TextDecoder().decode(b));
  console.log(c);
}
> run();
Uint32Array(3) [ 1, 2, 3 ]
Uint8Array(6) [ 65, 66, 67, 68, 69, 70 ] ABCDEF
Uint32Array(2) [ 4, 5 ]

When we need to call a WebAssembly function, we set as parameters the offset and the length of the array:

// Example of a call to a WebAssembly module
wasm.instance.exports.sum(c.byteOffset, c.length);

If more memory is required, then we can grow up the memory buffer with the grow method. You can decide to increase the memory buffer size by comparing the current size with a target size, and set a new number of pages:

const PAGE_SIZE = 64 * 1024;
let currentSize = memory.buffer.byteLength;
if (currentSize < newSize) {
    let nbPages = Math.ceil((newSize - currentSize) / PAGE_SIZE);
    console.log("grow memory up to ", nbPages, " * ", PAGE_SIZE);
    memory.grow(nbPages);
}

Out of Bounds Exception

From JavaScript, if we attempt to set a data beyond the end of an array we will get an exception

> run().then();
RangeError: offset is out of bounds

From JavaScript, if we attempt to get a data beyond the end of the array we will get an undefined value.

From the WebAssembly module, if we attempt to get or set some data beyond the memory buffer, then we will get an exception:

> run().then();
RuntimeError: memory access out of bounds

Table

A table is an array of function references. A single table can be defined by a WebAssembly module. A table allows to make an indirect call of a function implemented by the WebAssembly module using an index, and not directly with the function name.

For example, if the WebAssembly module allocates and exports this table, you can call a function from JavaScript as follow:

let table = wasm.instance.exports.table;
// indirect call of function at index 0, with the parameters [1, 2, 3]
table.get(0)(1, 2, 3); 

The WebAssembly module can also use the table on its own side to make indirect calls.

The main purpose of indirect calls is to replace the following switch statement:

let choice = ...;
switch (choice) {
    case 0: 
        doActionA(p1, p2); 
        break;
    case 1:
        doActionB(p1, p2);
        break;
    case 2:
        ...
  }    

with a simple indirect call statement:

let table = wasm.instance.exports.table;
let choice = ...
table.get(choice)(p1, p2);

This replacement improves performances when there is a lot of test cases, but also becomes mandatory if the mapping of the choice value on the function may change from time to time.

The index of a table may represent a state, or an identifier of an "object" of the WebAssembly module. For example, for our WebAssembly game, we have created four ships that can move in four directions: east, west, north, south. This module exposes the following interface in C programming language:

int positions[4][2]; // example of content: { {0,0}, {0,0}, {0,0}, {0,0} }
void moveToEast(int shipId);
void moveToWest(int shipId);
void moveToNorth(int shipId);
void moveToSouth(int shipId);
void (*table[4])(int id); // example of content: { moveToNorth, moveToNorth, moveToNorth, moveToNorth }
void gameLoop();

The members of the interface are:

void gameLoop()
{
    for (int id = 0; id < 4; id++) 
    {
        table[id](id); // Apply a move to the ship identified with the index
    }
}

The JavaScript call consists of initializing the table with functions to move a ship toward a direction, and then calling the gameLoop two times, each call will set a new position for all ships:

let run = async () => {
    try {
        let bytecode = await fetch("game/game.wasm");
        let wasm= await WebAssembly.instantiateStreaming(bytecode);

        // The WASM module identifies 4 ships identified with an integer ID from 0 to 3
        // Thanks to the table of functions we set an initial direction for each ship
        let exports = wasm.instance.exports; 
        let table = exports.table;
        table.set(0, exports.moveToEast);  // Ship  #0 will move to the East
        table.set(1, exports.moveToWest);  // Ship  #1 will move to the West 
        table.set(2, exports.moveToNorth); // Ship  #2 will move to the North
        table.set(3, exports.moveToSouth); // Ship  #3 will move to the South
  
        // Move the ships for 2 cycles  
        let gameLoop = exports.gameLoop;
        gameLoop();
        gameLoop();

        // Look at the current ships positions
        let positions = new Int32Array(exports.memory.buffer)
        console.log("Ship #0 locate at (" + positions[0] + ", " + positions[1] +")");
        console.log("Ship #1 locate at (" + positions[2] + ", " + positions[3] +")");
        console.log("Ship #2 locate at (" + positions[4] + ", " + positions[5] +")");
        console.log("Ship #3 locate at (" + positions[6] + ", " + positions[7] +")");
    }   
    catch(e) {  
        console.error(e);
    }
 };
> run().then();   
Ship #0 locate at (2, 0)
Ship #1 locate at (-2, 0)
Ship #2 locate at (0, 2)
Ship #3 locate at (0, -2)

Source code

To Go Further