When copying data kills performance
You are building a video editor in the browser. Rust decodes a frame into a Vec<u8> of pixels. JavaScript needs to draw those pixels to a canvas. If you pass the Vec through wasm-bindgen as a JsValue, the crate copies the bytes across the boundary. That copy takes time. For a 4K frame, the copy adds milliseconds of latency every frame. You want JavaScript to read the pixels directly from the memory where Rust stored them. No copy. Just a view.
Shared memory gives you that view. WebAssembly runs inside a linear memory buffer. Rust allocates data in that buffer. JavaScript sees the same buffer as an ArrayBuffer. A pointer in Rust is just an offset into that buffer. If you hand the offset to JavaScript, JavaScript can slice the buffer and read the data instantly.
The linear memory buffer
Think of the WASM memory as a giant shared whiteboard. Rust sits on the left side, JavaScript sits on the right. Both can see the whole board. When Rust allocates a Vec, it picks a spot on the whiteboard and writes the data there. The "pointer" is just the coordinates of that spot. When you pass a pointer to JavaScript, you are handing over a sticky note with the coordinates. JavaScript walks to that spot and reads what Rust wrote.
The whiteboard has a size. When the program starts, the browser allocates a block of memory. If Rust needs more space, the browser expands the whiteboard. This expansion is where things get tricky. If JavaScript is holding a reference to the old whiteboard size, it might miss the new space. The buffer reference can become stale.
wasm-bindgen exports the memory automatically. You don't need to manually expose the buffer. The crate adds a memory export to the WASM module. JavaScript imports that export and gets direct access to the linear memory.
Minimal example: Exporting a pointer
This example shows the raw mechanics. Rust allocates a vector, extracts the pointer and length, and returns them. JavaScript creates a Uint8Array view over the WASM memory.
use wasm_bindgen::prelude::*;
/// Returns a pointer and length for a static buffer.
/// This is a simplified example. Real code needs ownership management.
#[wasm_bindgen]
pub fn get_static_data() -> (u32, u32) {
// Allocate on the heap. The Vec owns the memory.
let data = vec![1u8, 2, 3, 4];
// Get a raw pointer to the first element.
// This is just an address in WASM memory.
let ptr = data.as_ptr() as u32;
// Get the length in bytes.
let len = data.len() as u32;
// Leak the Vec so the memory stays alive after this function returns.
// Without this, the Vec drops and the memory is freed.
std::mem::forget(data);
(ptr, len)
}
// Import the function and the memory buffer.
import { get_static_data, memory } from './pkg';
const [ptr, len] = get_static_data();
// Create a view over the WASM memory buffer.
// The view starts at the pointer offset and spans len bytes.
const view = new Uint8Array(memory.buffer, ptr, len);
console.log(view); // Uint8Array [1, 2, 3, 4]
The code works, but it has a problem. The std::mem::forget call leaks memory. The Vec is never dropped. The memory stays allocated until the page reloads. You need a way to clean up.
The ownership transfer
Rust's ownership rules prevent dangling pointers. When a Vec goes out of scope, Rust drops it and frees the memory. If JavaScript holds a pointer to that memory, the pointer becomes dangling. Accessing it causes undefined behavior.
You have to transfer ownership from Rust to JavaScript. Rust must give up the Vec so it doesn't drop it. The standard way is std::mem::forget. This tells Rust "I'm done with this value, don't clean it up." JavaScript now owns the memory. JavaScript is responsible for cleaning it up.
The cleanup requires a deallocation function. You export a function from Rust that takes the pointer and length, reconstructs the Vec, and lets Rust drop it. This is the C-FFI pattern. It works in WASM too.
Convention aside: The community calls this the "alloc/free" pattern. Always pair an allocation function with a deallocation function. Never export a forget without a matching free. If you leak memory in WASM, the browser tab consumes more RAM until the user closes it.
Realistic pattern: Alloc and free
This pattern shows the full lifecycle. Rust allocates, JavaScript uses, JavaScript calls back to free.
use wasm_bindgen::prelude::*;
/// Allocates a vector and returns a raw pointer and length.
/// Callers must call free_data to prevent leaks.
#[wasm_bindgen]
pub fn create_data() -> (u32, u32) {
let mut data = vec![1u8, 2, 3, 4];
// Get mutable pointer. JS might write back to this buffer.
let ptr = data.as_mut_ptr();
let len = data.len() as u32;
// Transfer ownership. Rust no longer manages this memory.
std::mem::forget(data);
(ptr as u32, len)
}
/// Frees memory allocated by create_data.
/// # Safety
/// The caller must ensure the pointer and length are valid.
#[wasm_bindgen]
pub unsafe fn free_data(ptr: u32, len: u32) {
// SAFETY:
// 1. ptr must be a valid pointer returned by create_data.
// 2. len must match the length passed to create_data.
// 3. This function must be called exactly once per allocation.
let _ = Vec::from_raw_parts(ptr as *mut u8, len as usize, len as usize);
}
import { create_data, free_data, memory } from './pkg';
// Allocate memory in Rust.
const [ptr, len] = create_data();
// Create a view to read or write data.
const view = new Uint8Array(memory.buffer, ptr, len);
// Modify data. Rust sees the changes.
view[0] = 99;
// Clean up when done.
free_data(ptr, len);
The free_data function reconstructs the Vec using Vec::from_raw_parts. When the Vec goes out of scope at the end of the function, Rust drops it and frees the memory. The // SAFETY: comment lists the invariants. If JavaScript violates any invariant, the program crashes or corrupts memory.
Treat the SAFETY comment as a proof. If you can't write the invariants, you don't have a safe wrapper.
The memory growth trap
WASM memory grows in pages. The default page size is 64KB. When Rust allocates more memory than the current buffer holds, the WASM runtime requests more memory from the browser. The browser allocates a new, larger ArrayBuffer and copies the old data into it.
This growth invalidates old buffer references. If JavaScript caches memory.buffer, the cache becomes stale. The cached buffer points to the old, freed memory. Creating a view over the stale buffer reads garbage.
Always read memory.buffer fresh when creating views. Don't cache the buffer. Cache the module, not the buffer.
// BAD: Caching the buffer.
const cachedBuffer = memory.buffer;
const view = new Uint8Array(cachedBuffer, ptr, len); // Stale after growth.
// GOOD: Reading buffer fresh.
const view = new Uint8Array(memory.buffer, ptr, len); // Always current.
Convention aside: memory.buffer is a getter. In modern browsers, accessing memory.buffer returns the current buffer. The getter handles the growth. You just need to call the getter every time.
Cache the module, not the buffer. The buffer changes when WASM grows.
Views for different types
The pointer is just an address. The type depends on the view. If Rust allocates a Vec<u32>, JavaScript needs a Uint32Array. The pointer is the same. The length is in elements, not bytes.
#[wasm_bindgen]
pub fn create_u32_data() -> (u32, u32) {
let data = vec![100u32, 200, 300];
let ptr = data.as_ptr() as u32;
// Length is in elements, not bytes.
let len = data.len() as u32;
std::mem::forget(data);
(ptr, len)
}
const [ptr, len] = create_u32_data();
// Use Uint32Array for 32-bit integers.
// The length is the number of elements.
const view = new Uint32Array(memory.buffer, ptr, len);
console.log(view); // Uint32Array [100, 200, 300]
If you use the wrong view, you read garbage. A Uint8Array over u32 data reads individual bytes. A Uint32Array reads 4-byte chunks. Match the view to the Rust type.
Pitfalls and compiler errors
Raw pointers require unsafe. If you try to dereference a raw pointer without unsafe, the compiler stops you.
If you forget unsafe around a raw pointer dereference, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe). The compiler forces you to acknowledge the risk.
If you try to mutate a value through a shared reference, you get E0502 (cannot borrow as mutable because it is also borrowed as immutable). Shared memory bypasses the borrow checker. You have to enforce the rules manually. If JavaScript writes to a buffer while Rust reads it, you get a data race.
Convention aside: Keep unsafe blocks small. The community calls this the "minimum unsafe surface" rule. Isolate the raw pointer manipulation in a helper function. The rest of your code stays safe.
Leak memory intentionally, but plan the cleanup. Every forget needs a free.
Decision matrix
Use wasm-bindgen types like String and Vec via JsValue when the data is small and you don't care about copy overhead. The crate handles conversion and memory management automatically.
Use shared memory with raw pointers when you have large buffers like images, audio samples, or game state that JavaScript reads or writes frequently. The copy cost dominates, and direct access is faster.
Use js-sys and web-sys when you need to interact with browser APIs that expect ArrayBuffer or TypedArray directly. These crates provide safe wrappers around the web platform.
Use #[wasm_bindgen] on structs when you want to expose a Rust object with methods to JavaScript. The crate generates the glue code for you.
Use the alloc/free pattern when you need fine-grained control over memory lifetime. This pattern works for any data layout and gives JavaScript full ownership.
Trust the deallocation contract. If JavaScript breaks the contract, your WASM module leaks memory until the page reloads.