| |

Rust 103 🦀 WebAssembly (Wasm) Compilation and Interoperability (wasm-bindgen)

⏱️ Estimated read: 15 min | |

WebAssembly is a portable bytecode format that runs in browsers, Node.js, and standalone runtimes. Rust compiles to it efficiently because the language has no garbage collector and produces small binaries. But the raw Wasm interface is narrow: it passes integers and floats, not strings, objects, or DOM nodes. wasm-bindgen is the bridge that lifts that restriction, generating the JavaScript glue and Rust-side machinery that make rich types cross the boundary.

This chapter covers the compilation pipeline and the interoperability layer. It begins with the target triple and the cdylib crate type that produce a .wasm file, then explains what wasm-bindgen generates and why the generated JavaScript is not optional. It addresses the memory model that connects Rust’s linear memory to JavaScript’s ArrayBuffer, the deployment targets that determine how the module is loaded, and the safety hazards that arise when JavaScript holds references into Wasm memory. The final sections cover the cases where the boundary crossing is the bottleneck and the patterns that minimize it.


Key point: wasm-bindgen does not eliminate the boundary. It automates the marshalling that you would otherwise write by hand. Every string, every object, every slice crossing between Rust and JavaScript still requires allocation, copying, and deallocation. The performance question is always how many times you cross, not whether the crossing is possible.


Why wasm-bindgen exists

The raw Wasm interface. A WebAssembly module exports functions that take numeric arguments and return numeric results. There is no string type, no object type, and no way to pass a JavaScript Array directly. A Rust function that takes &str cannot be called from JavaScript without a wrapper that allocates the string in Wasm memory, copies the bytes, passes the pointer and length, and then reads the result back . wasm-bindgen generates that wrapper.

The generated glue. The #[wasm_bindgen] attribute marks Rust functions, structs, and methods for export. The wasm-bindgen CLI reads the compiled .wasm file, finds the marked exports, and generates a JavaScript file that marshals arguments and return values according to their types . A function taking String gets a JS wrapper that allocates a buffer, copies the string, calls the Wasm export with a pointer and length, and frees the buffer afterward .

The deployment target. The generated JavaScript differs depending on where the module will run. The --target bundler mode produces ES module imports that Webpack and similar bundlers understand. --target web produces native ES modules for direct browser use. --target nodejs produces CommonJS requires. The target determines the loading mechanism, not the Wasm itself .

The memory model. Wasm has a single linear memory: a contiguous block of bytes that grows in 64 KiB pages. Rust allocates into this memory, and JavaScript can access it through the memory object exposed by the Wasm instance. The wasm-bindgen glue handles allocation and deallocation for the types it knows about, but raw pointer manipulation remains the programmer’s responsibility .


a. Compilation targets and the cdylib crate type

The first step is installing the target and configuring the crate. The wasm32-unknown-unknown target produces a Wasm module with no operating system dependencies, suitable for browsers and edge runtimes. The wasm32-wasip1 target produces a module that uses the WebAssembly System Interface, suitable for server-side runtimes like Wasmtime and WasmEdge .

# Cargo.toml
[lib]
# cdylib produces a dynamic library suitable for linking
# into a WebAssembly host environment.
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

The cdylib crate type is required because the default rlib produces a Rust-specific archive that the Wasm toolchain cannot consume. The cdylib type tells Cargo to produce a shared library with a C-compatible ABI, which is what the Wasm linker expects.

# Install the target.
rustup target add wasm32-unknown-unknown

# Build the Wasm module.
cargo build --target wasm32-unknown-unknown --release

# The output is target/wasm32-unknown-unknown/release/crate_name.wasm

The build produces a .wasm file, but it is not yet usable from JavaScript. The exported functions have mangled names, the string types are raw pointers, and there is no JavaScript glue. The wasm-bindgen CLI is the second step.

# Install the CLI.
cargo install -f wasm-bindgen-cli

# Generate the JavaScript bindings.
wasm-bindgen target/wasm32-unknown-unknown/release/crate_name.wasm \
  --out-dir ./pkg \
  --target web

The --target web flag produces an ES module that can be imported directly in a browser. The pkg/ directory contains the JavaScript wrapper, the processed .wasm file, and a TypeScript declaration file by default .


b. The generated wrapper and the type mapping

The #[wasm_bindgen] attribute on a Rust function tells the CLI to generate a JavaScript wrapper. The wrapper’s job is to translate between JavaScript’s types and the numeric types that the raw Wasm interface supports.

use wasm_bindgen::prelude::*;

// Export a function that takes a string and returns a string.
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

The generated JavaScript for this function is conceptually similar to the following. It allocates a buffer for the input string, copies the bytes, calls the Wasm export with the pointer and length, reads the result from the returned pointer, and frees both buffers .

// Conceptual representation of the generated wrapper.
export function greet(name) {
    const ptr = allocateString(name);
    const resultPtr = wasm.greet(ptr, name.length);
    const result = readString(resultPtr);
    freeString(resultPtr);
    return result;
}

The type mapping covers more than strings. Numbers map directly to their Wasm equivalents. JsValue represents any JavaScript value and is the escape hatch for types that wasm-bindgen does not know about. js_sys provides typed wrappers for JavaScript built-ins like Array, Promise, and Uint8Array. web_sys provides bindings for the DOM and Web APIs .

use wasm_bindgen::prelude::*;
use web_sys::console;

// Import a JavaScript function.
#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

// Export a function that calls the imported one.
#[wasm_bindgen]
pub fn greet_and_alert(name: &str) {
    alert(&format!("Hello, {}!", name));
}

The extern "C" block imports JavaScript functions into Rust. The #[wasm_bindgen] attribute on the block tells the CLI to generate the import glue, which resolves the function at module instantiation time .


c. Memory, safety, and the boundary cost

The Wasm linear memory is accessible from JavaScript through the memory object. The wasm-bindgen glue uses this for its own allocations, but the programmer can also access it directly. A Rust function can return a raw pointer into Wasm memory, and JavaScript can create a typed array view over that memory .

#[wasm_bindgen]
pub fn add_to_array(ptr: *mut i32, len: usize, value: i32) {
    // Reconstruct a mutable slice from the raw pointer.
    // Safety: the caller must ensure the pointer is valid
    // and points to at least `len` i32 values.
    let array: &mut [i32] = unsafe { std::slice::from_raw_parts_mut(ptr, len) };
    for x in array.iter_mut() {
        *x += value;
    }
}
// JavaScript side: write into Wasm memory, call the function, read back.
const memory = wasm.memory;
const jsArray = [1, 2, 3, 4, 5];
const ptr = 0; // Must be a valid offset in Wasm memory.
const wasmArray = new Int32Array(memory.buffer, ptr, jsArray.length);
wasmArray.set(jsArray);
add_to_array(ptr, jsArray.length, 10);
console.log(Array.from(wasmArray)); // [11, 12, 13, 14, 15]

The safety hazard is the lifetime of the memory view. The Int32Array is a view over the ArrayBuffer backing Wasm memory. If Wasm memory grows, the ArrayBuffer is detached and the view becomes invalid. JavaScript code that holds a view across an allocation or a call that might grow memory must recreate the view afterward.

A more subtle hazard involves passing references to JavaScript and retaining them. A &mut [u8] passed to an extern "C" function becomes a raw pointer on the JavaScript side. If the JavaScript code stores that pointer and writes to it after the Rust function has returned and the buffer has been deallocated, the write corrupts whatever the allocator reused the memory for. This is a memory safety violation that Rust cannot prevent because the JavaScript side is outside its control .

The cost of crossing the boundary is the cost of the marshalling. Every String crossing requires an allocation on one side, a copy, and a deallocation on the other. Every Vec requires the same. The performance strategy is to minimize the number of crossings: pass large buffers once rather than many small values, use Uint8Array and js_sys::Uint8Array for binary data, and avoid round-tripping strings when a numeric representation would suffice .


Complete Example Session

// ============================================
// PART 1: THE CRATE CONFIGURATION
// ============================================
// Cargo.toml must declare cdylib.

// [lib]
// crate-type = ["cdylib"]
//
// [dependencies]
// wasm-bindgen = "0.2"

// ============================================
// PART 2: THE SIMPLE EXPORT
// ============================================
// A function that takes and returns a string.
// wasm-bindgen generates the marshalling.

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

// ============================================
// PART 3: THE IMPORT
// ============================================
// Import a JavaScript function into Rust.

#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet_and_alert(name: &str) {
    alert(&greet(name));
}

// ============================================
// PART 4: THE STRUCT EXPORT
// ============================================
// Export a Rust struct as a JavaScript class.

#[wasm_bindgen]
pub struct Counter {
    count: u32,
}

#[wasm_bindgen]
impl Counter {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Counter {
        Counter { count: 0 }
    }

    pub fn increment(&mut self) -> u32 {
        self.count += 1;
        self.count
    }

    pub fn value(&self) -> u32 {
        self.count
    }
}

// ============================================
// PART 5: THE RAW MEMORY FUNCTION
// ============================================
// A function that operates on a buffer passed by pointer.

#[wasm_bindgen]
pub fn sum_buffer(ptr: *const i32, len: usize) -> i32 {
    // Safety: the caller guarantees the pointer is valid
    // and points to `len` i32 values.
    let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
    slice.iter().sum()
}

// ============================================
// PART 6: THE JS_SYS TYPES
// ============================================
// Use js_sys for typed JavaScript objects.

use js_sys::Uint8Array;

#[wasm_bindgen]
pub fn process_bytes(data: &Uint8Array) -> u32 {
    // to_vec copies the data into a Rust Vec.
    // This is a copy, but it is safe and simple.
    let bytes = data.to_vec();
    bytes.iter().map(|&b| b as u32).sum()
}

// ============================================
// PART 7: THE START FUNCTION
// ============================================
// A function that runs when the module is instantiated.

#[wasm_bindgen(start)]
pub fn main() {
    // Runs on module load.
    // Useful for setting up panic hooks or logging.
}

// ============================================
// PART 8: THE BUILD COMMANDS
// ============================================
// 1. rustup target add wasm32-unknown-unknown
// 2. cargo build --target wasm32-unknown-unknown --release
// 3. wasm-bindgen target/.../crate.wasm --out-dir pkg --target web

// ============================================
// PART 9: THE JAVASCRIPT USAGE
// ============================================
// import init, { greet, Counter, sum_buffer } from './pkg/crate.js';
//
// await init();
// console.log(greet("World"));
//
// const counter = new Counter();
// counter.increment();
// console.log(counter.value());

// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// wasm-bindgen generates the glue. The programmer
// decides what crosses and how often.

fn main() {}

The ten parts build a module that demonstrates string marshalling, JavaScript imports, class exports, raw memory access, and typed JavaScript objects. The pattern is consistent: the #[wasm_bindgen] attribute marks what crosses the boundary, and the CLI generates the code that makes the crossing work.


Quick Reference

Build Commands

StepCommand
Install targetrustup target add wasm32-unknown-unknown
Build Wasmcargo build --target wasm32-unknown-unknown --release
Generate bindingswasm-bindgen --out-dir pkg --target web
Or use wasm-packwasm-pack build --target web

Deployment Targets

TargetEnvironmentModule System
bundlerWebpack, ViteES modules for bundlers
webBrowserNative ES modules
nodejsNode.jsCommonJS
denoDenoES modules

Type Mapping

Rust TypeJavaScript TypeNotes
i32, u32, f64numberDirect
String, &strstringCopied
Vec<u8>Uint8ArrayCopied or pointer
JsValueAnyEscape hatch
js_sys::Uint8ArrayUint8ArrayTyped wrapper
web_sys::ElementDOM elementWeb API binding

Boundary Cost

PatternCostWhen to Use
String round-tripAllocate + copy + freeRare, small strings
Buffer by pointerAllocate + copyLarge binary data
Uint8Array::to_vecCopySafe, simple
Repeated small callsMany crossingsBatch instead

Best Practices

✅ Do This:

// Use cdylib for the crate type.
crate-type = ["cdylib"]                                              // ✅

// Use &str and String for text crossing the boundary.
pub fn greet(name: &str) -> String { ... }                           // ✅

// Batch data into a single buffer rather than many small calls.
pub fn process_all(data: &[u8]) -> Vec<u8> { ... }                   // ✅

// Use js_sys::Uint8Array for binary data from JavaScript.
pub fn process(data: &Uint8Array) -> u32 { ... }                     // ✅

// Document the safety requirements of raw pointer functions.
// Safety: caller guarantees ptr is valid and len is correct.        // ✅

❌ Don’t Do This:

// Don't return a pointer to a dropped Vec.
let ptr = vec.as_ptr(); return ptr; // Vec is dropped, ptr dangles  // ❌

// Don't hold a JS memory view across an allocation.
// The ArrayBuffer may be detached when Wasm memory grows.          // ❌

// Don't pass a &mut [u8] to JavaScript and retain it.
// The borrow ends when the function returns.                       // ❌

// Don't cross the boundary for every element.
for x in data { call_js(x); } // batch instead                       // ❌

// Don't use wasm-bindgen for DOM-heavy UI logic.
// JavaScript is the right tool for DOM manipulation.               // ❌

Common Pitfalls

PitfallWhy It HappensFix
Dangling pointerReturned Vec::as_ptr() after the Vec droppedUse Vec::into_raw_parts or mem::forget
Invalid memory viewWasm memory grew, ArrayBuffer detachedRecreate the view after growth
Memory corruptionJS retained a &mut [u8] past its lifetimeDo not pass mutable slices to JS
Build errorMissing cdylib crate typeAdd it to Cargo.toml
Import not foundWrong --target flagMatch the target to the environment
Slow performanceMany small boundary crossingsBatch data into single calls

Real-World Examples

1. Simple Export

#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 { a + b }

2. String Round-Trip

#[wasm_bindgen]
pub fn greet(name: &str) -> String { format!("Hi, {}", name) }

3. Struct as Class

#[wasm_bindgen]
pub struct Counter { count: u32 }

4. Import JavaScript

#[wasm_bindgen]
extern "C" { fn alert(s: &str); }

5. Raw Buffer

#[wasm_bindgen]
pub fn sum(ptr: *const i32, len: usize) -> i32 { ... }

6. Typed Array

use js_sys::Uint8Array;
#[wasm_bindgen]
pub fn process(data: &Uint8Array) -> u32 { ... }

7. Start Function

#[wasm_bindgen(start)]
pub fn main() { ... }

8. Build Wasm

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

9. Generate Bindings

wasm-bindgen --out-dir pkg --target web

10. Load in Browser

import init, { greet } from './pkg/crate.js';
await init();

Visual

The Compilation Pipeline

┌─────────────────────────────────────────────────────────┐
│  src/lib.rs                                             │
│  #[wasm_bindgen] pub fn greet(name: &str) -> String     │
│         │                                               │
│         ▼  cargo build --target wasm32-unknown-unknown  │
│  crate.wasm                                             │
│  (mangled names, raw pointers, no JS glue)              │
│         │                                               │
│         ▼  wasm-bindgen --out-dir pkg --target web      │
│  pkg/                                                   │
│  ├── crate.js      ← generated JS wrapper               │
│  ├── crate_bg.wasm ← processed Wasm                     │
│  └── crate.d.ts    ← TypeScript declarations            │
│         │                                               │
│         ▼  import in browser                            │
│  import init, { greet } from './pkg/crate.js'           │
└─────────────────────────────────────────────────────────┘

The String Marshalling

┌─────────────────────────────────────────────────────────┐
│  JAVASCRIPT: greet("World")                             │
│         │                                               │
│         ▼                                               │
│  Generated wrapper:                                     │
│  1. allocateString("World") → ptr, len                  │
│  2. wasm.greet(ptr, len)                                │
│         │                                               │
│         ▼                                               │
│  RUST: greet(name: &str) -> String                      │
│  reads bytes at ptr, len                                │
│  allocates result string                                │
│  returns result pointer                                 │
│         │                                               │
│         ▼                                               │
│  Generated wrapper:                                     │
│  3. readString(resultPtr) → "Hello, World!"             │
│  4. freeString(ptr)                                     │
│  5. freeString(resultPtr)                               │
│         │                                               │
│         ▼                                               │
│  JAVASCRIPT: "Hello, World!"                            │
└─────────────────────────────────────────────────────────┘

The Memory Model

┌─────────────────────────────────────────────────────────┐
│  WASM LINEAR MEMORY (ArrayBuffer)                       │
│                                                         │
│  ┌───────────────────────────────────────────────────┐  │
│  │  Rust allocations (Vec, String, Box)              │  │
│  │  wasm-bindgen glue allocations                    │  │
│  │                                                   │  │
│  │  JS can create views:                             │  │
│  │  new Int32Array(memory.buffer, ptr, len)          │  │
│  │                                                   │  │
│  │  If memory grows, the ArrayBuffer is DETACHED.    │  │
│  │  All views become invalid. Recreate them.         │  │
│  └───────────────────────────────────────────────────┘  │
│                                                         │
│  Page size: 64 KiB. Growth: memory.grow(n).             │
└─────────────────────────────────────────────────────────┘

Deployment Targets

┌─────────────────────────────────────────────────────────┐
│  --target web                                           │
│  Browser: <script type="module">                        │
│  import init from './pkg/crate.js';                     │
│                                                         │
│  --target bundler                                       │
│  Webpack/Vite: import { greet } from 'crate';           │
│                                                         │
│  --target nodejs                                        │
│  Node: const { greet } = require('./pkg/crate.js');     │
│                                                         │
│  --target deno                                          │
│  Deno: import { greet } from './pkg/crate.js';          │
└─────────────────────────────────────────────────────────┘

Summary

ItemValue
Crate typecdylib
Targetwasm32-unknown-unknown
Bindings generatorwasm-bindgen CLI
Attribute#[wasm_bindgen]
String handlingAllocate, copy, free (generated)
Raw memorymemory.buffer, typed array views
Deployment targetsweb, bundler, nodejs, deno
Memory growthDetaches ArrayBuffer, invalidates views
Safety hazardRetained JS references past Rust lifetime

Key takeaways:

  • wasm-bindgen generates the glue that makes rich types cross the boundary. Without it, strings, objects, and classes cannot pass between Rust and JavaScript .
  • The crate type must be cdylib. The default rlib produces a Rust archive that the Wasm toolchain cannot consume.
  • The --target flag determines the JavaScript module format. web for browsers, bundler for Webpack, nodejs for Node. Match the target to the environment .
  • Every string crossing requires allocation, copy, and free. The generated wrapper handles it, but the cost is real. Minimize crossings for performance .
  • Raw pointers into Wasm memory are the programmer’s responsibility. A returned pointer must remain valid, and a JS view over the ArrayBuffer must be recreated after memory growth.
  • Retaining a &mut [u8] in JavaScript is unsound. The borrow ends when the Rust function returns. JavaScript writes after that point corrupt memory .
  • Batch data to reduce boundary crossings. One large buffer is faster than many small calls. Use Uint8Array and js_sys types for binary data.
  • wasm-pack wraps the build and bindgen steps. It is the recommended tool for most projects, producing a pkg/ directory ready for npm or direct import.

Remember: WebAssembly is a compilation target, not a runtime environment. The Rust code compiles to instructions that operate on a linear memory, and wasm-bindgen builds the bridge between that memory and the JavaScript heap. The bridge is necessary because the raw interface is numeric, and the cost is the marshalling that the generated glue performs. Write the Rust functions you need, mark the exports with #[wasm_bindgen], build with the correct target, and let the CLI generate the wrapper. Measure the boundary crossings before optimizing them, and treat raw pointer functions with the same care you would treat any unsafe code in Rust. The safety of the Wasm module depends on the Rust code; the safety of the interaction depends on the JavaScript code that calls it.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!