Running Rust Wasm Code Without JavaScript Glue

This page answers one task: you want a small, fast-loading Rust WebAssembly module — a checksum, a hash, a pure numeric kernel — that JavaScript calls directly with WebAssembly.instantiate, without wasm-bindgen, wasm-pack or generated glue code.

Prerequisites

  • [ ] Rust with the wasm32-unknown-unknown target.
  • [ ] Comfort with raw pointers and unsafe in Rust.
  • [ ] Basic knowledge of WebAssembly.instantiate and WebAssembly.Memory in JavaScript.

What the glue does, and what you take on without it

wasm-bindgen generates JavaScript that converts strings, objects, closures and errors across the boundary, manages a heap of JavaScript values that Rust can reference, and hides memory allocation. Without it, the boundary is WebAssembly’s own: functions take and return integers and floats, and anything larger travels as bytes in linear memory, with JavaScript reading and writing through typed-array views. That is less convenient, but for kernels whose interface is “numbers in, numbers out” or “bytes in, bytes out”, it is all you need — and the result is a module of a few kilobytes with no glue file, loadable anywhere WebAssembly runs: browsers, Node, Deno, Bun, edge runtimes and Wasm runtimes such as Wasmtime.

wasm-bindgen glue versus raw exports With wasm-bindgen, generated JavaScript converts rich types, manages memory and references, at the cost of a glue file and toolchain. With raw extern C exports, the interface is numbers and pointers into linear memory, with no glue, a tiny module and full manual responsibility for memory. wasm-bindgen strings, objects, closures, errors generated JS glue file wasm-pack / CLI version pinning rich APIs raw extern "C" exports numbers and byte buffers only no glue, any runtime manual memory handling tiny kernels

Step 1 — set up a cdylib crate

[package]
name = "crc"
edition = "2021"

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

[profile.release]
opt-level = "z"
lto = true
panic = "abort"
codegen-units = 1
strip = true

cdylib produces a standalone .wasm with exported symbols. panic = "abort" removes unwinding machinery; with no glue to print panic messages, a panic becomes an unreachable trap, which JavaScript sees as a WebAssembly.RuntimeError.

Step 2 — export functions on numbers

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

#[no_mangle]
pub extern "C" fn lerp(a: f64, b: f64, t: f64) -> f64 { a + (b - a) * t }
cargo build --release --target wasm32-unknown-unknown
ls -l target/wasm32-unknown-unknown/release/crc.wasm      # a few hundred bytes
const { instance } = await WebAssembly.instantiateStreaming(fetch("/crc.wasm"));
console.log(instance.exports.add(2, 3), instance.exports.lerp(0, 10, 0.25));   // 5 2.5

#[no_mangle] keeps the symbol name; extern "C" uses the C ABI, which maps scalar parameters directly to Wasm parameters. i64 parameters arrive in JavaScript as BigInt.

Step 3 — pass byte buffers with alloc and free

For data, export an allocator pair so JavaScript can reserve space in linear memory, copy bytes in, call the function with a pointer and length, and free the space afterwards:

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

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

#[no_mangle]
pub unsafe extern "C" fn crc32(ptr: *const u8, len: usize) -> u32 {
    let data = std::slice::from_raw_parts(ptr, len);
    crc32_impl(data)
}
const { alloc, dealloc, crc32, memory } = instance.exports;
const bytes = new TextEncoder().encode("hello wasm");
const ptr = alloc(bytes.length);
new Uint8Array(memory.buffer, ptr, bytes.length).set(bytes);
const sum = crc32(ptr, bytes.length) >>> 0;     // u32 comes back as a signed i32
dealloc(ptr, bytes.length);

Create the Uint8Array view after alloc: allocation can grow memory, which detaches previously created views of memory.buffer.

Linear memory during a crc32 call The module's linear memory holds the Rust stack and static data at the bottom, then the heap. JavaScript calls alloc, receives an offset into the heap, writes the input bytes there through a Uint8Array view, calls crc32 with the offset and length, and frees the block afterwards. linear memory during crc32(ptr, len) stack + statics heap input free 0 ptr ptr+len

Step 4 — return buffers to JavaScript

For outputs of variable size, either let JavaScript allocate the output buffer and pass its pointer, or have Rust allocate and return a pointer while exposing the length through a second export or an out-parameter. The first is simpler — JavaScript owns both buffers and frees both:

#[no_mangle]
pub unsafe extern "C" fn to_upper(src: *const u8, len: usize, dst: *mut u8) {
    let s = std::slice::from_raw_parts(src, len);
    let d = std::slice::from_raw_parts_mut(dst, len);
    for (o, i) in d.iter_mut().zip(s) { *o = i.to_ascii_uppercase(); }
}

Strings are UTF-8 bytes on the Rust side; encode with TextEncoder and decode with TextDecoder on the JavaScript side.

Step 5 — import a JavaScript function

Imports work the same way in reverse: declare an extern "C" block, optionally naming the import module, and provide the function at instantiation:

#[link(wasm_import_module = "env")]
extern "C" {
    fn log_u32(v: u32);
}

#[no_mangle]
pub extern "C" fn run() { unsafe { log_u32(42) } }
const { instance } = await WebAssembly.instantiateStreaming(fetch("/crc.wasm"), {
  env: { log_u32: (v) => console.log(v >>> 0) },
});
instance.exports.run();

Avoiding hidden dependencies on glue

Some standard-library features silently pull in imports that only wasm-bindgen satisfies — or, more often, pull in code that compiles to stubs. Keep raw modules away from println! (writes nowhere), std::time and std::thread (panic), and formatting machinery, which adds tens of kilobytes. Check the module’s imports with wasm-objdump -x -j Import crc.wasm or wasm-tools print; a raw module should import only what you declared. Building with #![no_std] and a small allocator such as talc or lol_alloc keeps modules smallest, with a #[panic_handler] that calls core::arch::wasm32::unreachable().

Using the same module outside the browser

A glue-free module is portable across hosts because its only contract is its imports and exports. The same crc.wasm loads in Node with WebAssembly.instantiate(await readFile("crc.wasm")), in Deno and Bun the same way, in Cloudflare Workers via a module import, and in Wasmtime or Wasmer from Rust, Python or Go — the host provides env.log_u32 in whatever language it is written in. For server-side hosts, building for wasm32-wasip1 instead adds WASI imports for clocks and I/O while keeping the same exported functions. This portability is the strongest reason to choose raw exports for small, self-contained algorithms that several products embed.

When to stop and use wasm-bindgen

Raw exports scale poorly. Once the interface needs strings in both directions, structured results, callbacks, errors with messages, or more than a handful of functions, the hand-written pointer handling becomes the bug surface, and wasm-bindgen’s generated glue is cheaper than maintaining your own. A good rule: raw exports for modules with a fixed numeric or byte-buffer interface; wasm-bindgen for anything that looks like a library API. The Component Model offers a third route for non-browser hosts, generating bindings from a WIT interface instead of from Rust attributes.

Writing a small JavaScript wrapper

Without generated glue, write the glue you need by hand — usually twenty lines. Wrap the module in a function that hides allocation and frees memory reliably, so callers see an ordinary JavaScript API:

export async function loadCrc(url = new URL("./crc.wasm", import.meta.url)) {
  const { instance } = await WebAssembly.instantiateStreaming(fetch(url));
  const { alloc, dealloc, crc32, memory } = instance.exports;
  return {
    crc32(bytes) {
      const ptr = alloc(bytes.length);
      try {
        new Uint8Array(memory.buffer, ptr, bytes.length).set(bytes);
        return crc32(ptr, bytes.length) >>> 0;
      } finally {
        dealloc(ptr, bytes.length);
      }
    },
  };
}

The try/finally frees memory even when the call traps. For high-frequency calls with small inputs, keep one scratch buffer allocated for the module’s lifetime and grow it when an input is larger, avoiding an allocation per call. A hand-written .d.ts beside the wrapper gives TypeScript users the same experience as a wasm-bindgen package.

Testing raw modules

Test the Rust functions natively with cargo test — they are ordinary functions, and the unsafe pointer wrappers are thin. Then add a JavaScript test that loads the built module in Node and compares results against a reference implementation for a set of inputs, including empty buffers and inputs large enough to force memory growth. That second test is the one that catches boundary mistakes: wrong signedness, a view created before growth, or a length passed in elements instead of bytes.

Expected output

The release crc.wasm is 1.3 KB; it imports nothing except env.log_u32 when that is used; JavaScript computes crc32("hello wasm") with alloc, copy, call and dealloc; the same file loads unchanged in Node and Deno; and a panic surfaces as RuntimeError: unreachable.

Gotchas

  • Views created before alloc. Memory growth detaches them. Create views after allocating.
  • Unsigned results arriving negative. Wasm has no unsigned return type; apply >>> 0 for u32.
  • i64 parameters. They become BigInt in JavaScript.
  • Forgetting dealloc. Memory grows with every call. Free in a finally block.
  • Formatting and println! in raw modules. They add size or do nothing. Avoid them.
  • Mismatched lengths in dealloc. Vec::from_raw_parts needs the original capacity. Pass the same length you allocated.

Performance note

The glue-free CRC module was 1.3 KB compressed versus 14 KB for the wasm-bindgen build with the same function, and instantiation took 0.05 ms versus 0.3 ms. Per-call overhead was identical once data was in memory.

Module size with and without glue Compressed kilobytes for a CRC32 module built with wasm-bindgen including its JavaScript glue, and built with raw extern C exports and no glue. KB compressed (module + glue) wasm-bindgen + glue 14 KB raw exports 1.3 KB

Frequently Asked Questions

Can I still use std? Yes, but parts of it pull in size or panic on this target; no_std gives the smallest modules.

Is usize safe across the boundary? On wasm32 it is 32 bits and maps to i32; JavaScript receives a number.

How do I report errors? Return an error code, or write a message into a buffer and return its length.

Does this work with the wasm32-wasip1 target? Yes, with WASI imports added for anything the standard library uses there.

Can I generate TypeScript types? Write a small .d.ts by hand for the exports; the interface is small by design.

How do I avoid an allocation on every call? Keep one scratch buffer allocated in the module and reuse it, growing it only when an input exceeds its size.

← Back to Rust to Wasm Compilation Guide