fewer computer

wasm on the web for simpletons

2025-06-21

The only thing I really like doing on the computer is moving values around in memory. Because of this, I've never bothered learning anything about JavaScript.

But Rust + wasm-pack break now and then, so I figured it would be worth getting to the bottom of things. A later post will figure out how to import Rust types into JS, which is the real nice part about wasm-pack. For now, I just want to get a bare-bones Rust program running on the web without any tooling in between.

It didn't end up being too hard. Passing hello, world! takes eight lines of JavaScript and four lines of rust. The resulting library is 373 bytes which (lol) grows to 680 bytes when it gets gzipped over the wire.

Here's the js:

WebAssembly.instantiateStreaming(fetch("target/wasm32-unknown-unknown/release/wasmaudio.wasm")).then(
    (wasm_obj) => {
        let hello_string_ptr = wasm_obj.instance.exports.hello_world();
        const wasm_memory = new Uint8Array(wasm_obj.instance.exports.memory.buffer, hello_string_ptr, 13);
        const decoder = new TextDecoder('utf-8');
        alert(decoder.decode(wasm_memory))
    },
);

And here's the rust:

#[unsafe(no_mangle)]
extern "C" fn hello_world() -> *const u8 {
    "hello, world!".as_ptr()
}

I'll explain how all this works and how to expand it.

Compiling some wasm

With rust, this is easy.

Add

[lib]
crate-type = ["cdylib"]

To your Cargo.toml, throw the hello_world function into src/lib.rs, and you've made a little library. Compile with cargo build --target wasm32-unknown-unknown.

no_mangle ensures that our wasm module exports the same function names we see in rust. Returning a pointer means that JS can call the function and get an offset into the running module's memory.

extern tells the compiler to ensure compatibility with other ABIs. The "C" ABI is the default, so it's not actually necessary to include here. The "C" ABI is "whatever default your C compiler supports." - I'm not sure if there are any defaults that would be incompatible with wasm, but watch out. It happened to clang at least once. Anyways.

If the other stuff seems weird, check out MDN's WebAssembly Concepts to learn more about WebAssembly FFI.

Loading and running wasm

The main workhorse is the instantiateStreaming function. It's pretty frictionless, and gives you an immediate handle to your wasm module.

It can be called with 1-3 parameters (the first the wasm source, the second importObject providing import handles and the third compileOptions). The source is all we need for now.

index.js should have something like

const wasm_obj = await WebAssembly.instantiateStreaming(fetch("target/wasm32-unknown-unknown/debug/wasmtest.wasm"));

or

WebAssembly.instantiateStreaming(fetch("target/wasm32-unknown-unknown/debug/wasmtest.wasm")).then(
  (wasm_obj) => { },
);

wasm_obj is a ResultObject with two fields: module and instance. Instance is the handier of the two, as it provides a JS handle to the exported wasm functions.

These exports can be viewed with wasm2wat <output>.wasm:

(module $wasmaudio.wasm
  (type (;0;) (func (result i32)))
  (func $hello_world (type 0) (result i32)
    i32.const 1048576)
  (table (;0;) 1 1 funcref)
  (memory (;0;) 17)
  (global $__stack_pointer (mut i32) (i32.const 1048576))
  (global (;1;) i32 (i32.const 1048589))
  (global (;2;) i32 (i32.const 1048592))
  (export "memory" (memory 0))
  (export "hello_world" (func $hello_world))
  (export "__data_end" (global 1))
  (export "__heap_base" (global 2))
  (data $.rodata (i32.const 1048576) "hello, world!"))

So our wasm_obj.instance.exports will give us access to our wasm's memory buffer, the hello_world function, and some pointers to segment boundaries.

interfacing with wasm

Add

alert(wasm_obj.instance.exports.hello_world());

to index.js to call our hello_world function.

Additionally, create and serve an index.html with

<html>
    <head>
        <script type="module" src="./index.js"> </script>
    </head>
</html>

I usually use npx serve, as http-server doesn't support MIME types or whatever, which is required to fetch anything.

Visiting the page should serve an alert with an integer in it. Hooray! This integer is a pointer to a spot in wasm's memory (it should match your module's (data $.rodata (i32.const 1048576) "hello, world!")).

Parsing this into a string is fun, because we get to use pointers in JavaScript.

Interpeting wasm values

The two exports we care about are our hello_world function and the memory buffer. hello_world returns a pointer to somewhere in the module's memory buffer.

We can create a new view into this buffer with TypedArray. This is cheap (zero copy), and inspecting it reveals that it's just an array of bytes.

We can use our hello_string_ptr as the start of a small view into the module's memory or we could create a huge view with buffer.byteLength and use hello_string_ptr as an offset for slicing. Here's the former:

const wasm_memory = new Uint8Array(wasm_obj.instance.exports.memory.buffer, hello_string_ptr, 13);

Now all we need to do is decode this view into a string. The simplest is with a TextDecoder:

const decoder = new TextDecoder('utf-8');
alert(decoder.decode(wasm_memory))

This alerts "hello, world!" to the screen!

Appendix

Further reading

release (no_mangle):

(module $wasmaudio.wasm
  (type (;0;) (func (result i32)))
  (func $hello_world (type 0) (result i32)
    i32.const 1048576)
  (table (;0;) 1 1 funcref)
  (memory (;0;) 17)
  (global $__stack_pointer (mut i32) (i32.const 1048576))
  (global (;1;) i32 (i32.const 1048589))
  (global (;2;) i32 (i32.const 1048592))
  (export "memory" (memory 0))
  (export "hello_world" (func $hello_world))
  (export "__data_end" (global 1))
  (export "__heap_base" (global 2))
  (data $.rodata (i32.const 1048576) "hello, world!"))

release (mangled):

(module $wasmaudio.wasm
  (table (;0;) 1 1 funcref)
  (memory (;0;) 16)
  (global $__stack_pointer (mut i32) (i32.const 1048576))
  (global (;1;) i32 (i32.const 1048576))
  (global (;2;) i32 (i32.const 1048576))
  (export "memory" (memory 0))
  (export "__data_end" (global 1))
  (export "__heap_base" (global 2)))

stuff that maybe could make this better

I haven't tried this yet.