Rust to Wasm: Compiling Without Helpers

This chapter compiles a Rust crate to a .wasm file directly, with no JS glue, so you can see what wasm-bindgen is doing for you.

Why Skip wasm-bindgen First

wasm-bindgen is great. It's also magic. Before you adopt it, spend half an hour on the bare-metal path: you'll understand what it's hiding, and you'll pick simpler tools when they fit.

This chapter uses the wasm32-unknown-unknown target, #[no_mangle] exports, and manual boundary handling. By the end, you have a Wasm module with custom imports and exports, built from plain Rust.

The Target

wasm32-unknown-unknown means "32-bit Wasm, no OS, no libc". Your code can use core Rust (math, alloc, Vec, String) but can't call std::fs, std::net, or anything that needs an operating system.

rustup target add wasm32-unknown-unknown

For server-side Wasm with OS-ish features, use wasm32-wasip1 (Chapter 8). For now, we stay browser-compatible.

Crate Layout

A Wasm-producing crate looks like a Rust library, with one addition.

Cargo.toml:

[package]
name = "counter"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]
  • cdylib tells Cargo to produce a C-compatible dynamic library, which is what Wasm expects.
  • rlib is optional: the standard Rust library format, lets other crates use this one as a dependency.

Without cdylib, you get an .rlib file, not a .wasm file.

Exporting Functions

To make a function callable from the host, use #[no_mangle] and extern "C":

#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}
  • #[no_mangle] keeps the symbol name intact (Rust would otherwise encode types into the name).
  • extern "C" forces C calling conventions, which is what Wasm uses.
  • pub makes the item visible to the linker.

Build:

cargo build --target wasm32-unknown-unknown --release

Inspect:

wasm2wat target/wasm32-unknown-unknown/release/counter.wasm | head
(module
  (type (;0;) (func (param i32 i32) (result i32)))
  (func $add (type 0) (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add)
  ...
  (export "add" (func $add)))

Your Rust function, in Wasm.

Importing Host Functions

To call a function the host provides, declare it in an extern "C" block:

extern "C" {
    fn host_log(ptr: *const u8, len: usize);
}

#[no_mangle]
pub extern "C" fn greet() {
    let msg = b"hello from rust\n";
    unsafe {
        host_log(msg.as_ptr(), msg.len());
    }
}

The module now has an import: the host must supply host_log at instantiation. The default import module name is env (you can override with #[link(wasm_import_module = "...")]).

From JS:

const { instance } = await WebAssembly.instantiateStreaming(
  fetch("counter.wasm"),
  {
    env: {
      host_log: (ptr, len) => {
        const mem = new Uint8Array(instance.exports.memory.buffer);
        console.log(new TextDecoder().decode(mem.slice(ptr, ptr + len)));
      },
    },
  },
);

instance.exports.greet();
// hello from rust

Same pattern as Chapter 3: Wasm has bytes in linear memory; it passes a pointer and length; the host reads.

A Slightly Realistic Module

Counter with allocation for strings:

use std::sync::atomic::{AtomicI32, Ordering};

static COUNT: AtomicI32 = AtomicI32::new(0);

#[no_mangle]
pub extern "C" fn increment() -> i32 {
    COUNT.fetch_add(1, Ordering::Relaxed) + 1
}

#[no_mangle]
pub extern "C" fn get_count() -> i32 {
    COUNT.load(Ordering::Relaxed)
}

#[no_mangle]
pub extern "C" fn reset() {
    COUNT.store(0, Ordering::Relaxed);
}

// Allocate a buffer the host can write into
#[no_mangle]
pub extern "C" fn alloc(size: usize) -> *mut u8 {
    let mut buf = Vec::with_capacity(size);
    let ptr = buf.as_mut_ptr();
    std::mem::forget(buf);
    ptr
}

// Free a buffer the module allocated
#[no_mangle]
pub unsafe extern "C" fn dealloc(ptr: *mut u8, size: usize) {
    let _ = Vec::from_raw_parts(ptr, 0, size);
}

Five exports: counter operations and alloc/dealloc helpers for strings. That's enough to support host-driven workflows without wasm-bindgen.

Size Optimization

Default Rust builds produce Wasm around 1.5 MB for a trivial program. Most of that is standard library and panic infrastructure.

A few knobs to cut it down.

Cargo.toml release profile

[profile.release]
opt-level = "z"       # optimize for size
lto = true            # link-time optimization
codegen-units = 1     # better inlining at the cost of compile time
panic = "abort"       # no unwind tables
strip = true          # remove debug symbols

The most impactful: panic = "abort" (cuts ~50 KB of unwind machinery) and opt-level = "z".

wasm-opt

After Cargo builds, run the binary through wasm-opt (from the binaryen project):

wasm-opt -Oz target/wasm32-unknown-unknown/release/counter.wasm -o counter.min.wasm

-Oz optimizes for size. -O3 for speed. Often shaves another 10 to 30%.

Avoid the Standard Library When You Can

#![no_std] cuts the most weight but limits what you can do. For pure-computation modules, it's worth it.

#![no_std]

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }

#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 { a + b }

A #![no_std] hello-add is around 150 bytes. std adds a lot.

Inspecting with wasm2wat

Always sanity-check your output.

wasm2wat counter.wasm | head -50

Look for:

  • Unused imports. If your code pulls in __wbindgen_throw you didn't write, something extra got linked.
  • Large data sections. String constants and static tables live there. If something's 300 KB of static data, you probably have formatters you don't use.
  • Unexpected exports. Anything you didn't mark #[no_mangle] shouldn't appear by name.

For deeper inspection, wasm-objdump -d (from wabt) disassembles to a more traditional assembly view.

Memory in Rust Wasm

Rust's standard allocator works fine in Wasm. Vec::new(), Box::new(), String::from("x") all work. They allocate within Wasm's linear memory.

When you call alloc(n) from the host, you're using Rust's allocator through an exported function. The pointer you get is an offset into the Wasm module's linear memory. The host can read and write there.

A Full Example

A module that takes a string from the host, reverses it, and writes the result back:

#[no_mangle]
pub extern "C" fn alloc(size: usize) -> *mut u8 {
    let mut buf = Vec::with_capacity(size);
    let ptr = buf.as_mut_ptr();
    std::mem::forget(buf);
    ptr
}

#[no_mangle]
pub unsafe extern "C" fn dealloc(ptr: *mut u8, size: usize) {
    let _ = Vec::from_raw_parts(ptr, 0, size);
}

#[no_mangle]
pub unsafe extern "C" fn reverse(ptr: *mut u8, len: usize) {
    let slice = std::slice::from_raw_parts_mut(ptr, len);
    slice.reverse();
}

From JS:

const text = "hello";
const bytes = new TextEncoder().encode(text);
const ptr = instance.exports.alloc(bytes.length);
const view = new Uint8Array(instance.exports.memory.buffer);
view.set(bytes, ptr);

instance.exports.reverse(ptr, bytes.length);

const reversed = new TextDecoder().decode(view.slice(ptr, ptr + bytes.length));
console.log(reversed);    // "olleh"

instance.exports.dealloc(ptr, bytes.length);

No wasm-bindgen. Every byte of the boundary is code you can see.

When To Stop Here and Reach for wasm-bindgen

If your Wasm module has:

  • A handful of integer-in, integer-out functions: stay bare.
  • Lots of strings, structs, and objects crossing the boundary: use wasm-bindgen.
  • You're shipping to npm as a library: use wasm-pack, which uses wasm-bindgen.
  • You want to call DOM APIs from Rust: use wasm-bindgen with web-sys.

Chapter 6 picks up with wasm-bindgen doing all the boilerplate shown here, plus a lot more.

Common Pitfalls

Default crate-type. Without cdylib, no .wasm output.

Forgetting #[no_mangle]. The function exists in the binary but not as a named export.

Missing extern "C". ABI mismatch. The function might work, but you're relying on Rust's internal calling convention.

println! in Wasm. There's no stdout in wasm32-unknown-unknown. Use an imported host log function (shown above) or switch to WASI for stdout.

Large binaries. If your 20-line module produces 800 KB of Wasm, you've pulled in formatters (from panic! or println!). Apply the size optimizations above.

Dangling pointers from alloc/dealloc. If the host allocs and forgets to dealloc, the module leaks. If it deallocs the wrong size, you get undefined behavior.

Next Steps

Continue to 05-javascript-interop.md to run the module in a browser.