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-unknowntarget. - [ ] Comfort with raw pointers and
unsafein Rust. - [ ] Basic knowledge of
WebAssembly.instantiateandWebAssembly.Memoryin 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.
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.
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
>>> 0foru32. i64parameters. They becomeBigIntin JavaScript.- Forgetting
dealloc. Memory grows with every call. Free in afinallyblock. - Formatting and
println!in raw modules. They add size or do nothing. Avoid them. - Mismatched lengths in
dealloc.Vec::from_raw_partsneeds 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.
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.
Related
- Building Rust Wasm without wasm-pack — the CLI route with glue.
- Removing panic and formatting bloat from Rust Wasm — smaller modules.
- Replacing the default allocator to save bytes — allocator choices.
- Reading the glue code wasm-bindgen generates — what you are replacing.
← Back to Rust to Wasm Compilation Guide