Skip to Content
Course content

214: Sharing Memory Between Rust and JavaScript

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you first start plumbing Rust into a web app via WebAssembly, your instinct is usually to treat the boundary like a standard API. You pass a slice of data into a Rust function, Rust processes it, and then you return a Vec<u8> or a String back to JavaScript. It feels clean, it's type-safe, and it just works. But once you start dealing with anything larger than a few kilobytes—say, an image buffer for a real-time filter—you'll notice a massive performance hit that has nothing to do with your actual algorithm.

The overhead of the "Copy-Everything" approach

The problem is that Rust and JavaScript don't actually share the same heap. When you return a Vec<u8> from a wasm-bindgen function, Rust isn't just handing JS a pointer to the memory. Instead, wasm-bindgen allocates new memory on the JavaScript side and copies every single byte from the Wasm linear memory into that new JS array. If you're processing a 4K image, you're copying roughly 33 megabytes of data every single frame. You've essentially turned your high-performance Rust engine into a bottleneck because you're spending all your CPU cycles moving memory around rather than calculating pixels.

// The naive way: High overhead for large data
#[wasm_bindgen]
pub fn apply_grayscale(pixels: &[u8]) -> Vec<u8> {
    let mut output = pixels.to_vec();
    for chunk in output.chunks_mut(4) {
        let gray = (chunk[0] as u32 + chunk[1] as u32 + chunk[2] as u32) / 3;
        chunk[0] = gray as u8;
        chunk[1] = gray as u8;
        chunk[2] = gray as u8;
    }
    output // This triggers a full copy back to JS
}

Directly viewing the Wasm heap

The better way is to stop thinking about "returning" data and start thinking about "exposing" it. Rust's memory is just one big contiguous array (the WebAssembly.Memory object). If Rust allocates a buffer and keeps it alive, JavaScript can read that memory directly without copying a single byte. We do this by returning a pointer (the memory offset) and the length of the data to JavaScript, then wrapping that slice of the Wasm heap in a JS TypedArray.

I usually do this by creating a static buffer or a long-lived struct that holds the data. By returning the pointer to the start of the buffer, JavaScript can create a Uint8Array that acts as a "window" into the Rust memory. It's incredibly fast because the JS array is just a view; it's not a copy.

// The professional way: Zero-copy memory sharing
#[wasm_bindgen]
pub struct ImageProcessor {
    buffer: Vec<u8>,
}

#[wasm_bindgen]
impl ImageProcessor {
    pub fn new(size: usize) -> Self {
        Self { buffer: vec![0; size] }
    }

    pub fn get_ptr(&self) -> *const u8 {
        self.buffer.as_ptr()
    }

    pub fn process(&mut self, input: &[u8]) {
        // Perform calculations directly into self.buffer
        // ...
    }
}

On the JavaScript side, you'd use it like this: const view = new Uint8Array(wasm.memory.buffer, processor.get_ptr(), size);. Now, any change Rust makes to that buffer is instantly visible in JS.

The "Memory Growth" trap

There is a catch here, and it's one that has bitten me more times than I'd like to admit. WebAssembly memory can grow. If Rust decides it needs more space and calls memory.grow(), the underlying ArrayBuffer in JavaScript is detached and invalidated. Your Uint8Array view suddenly becomes a dead object, and trying to access it will throw an error.

If your buffer size is constant—like a fixed-resolution canvas—this isn't an issue. But if you're dynamically resizing your data, you cannot cache the JS view. You must re-create the Uint8Array every time you call a Rust function that might trigger an allocation. It sounds like a chore, but creating a TypedArray view is an incredibly cheap operation compared to copying megabytes of data. Always prioritize the fresh view over the cached one if there's any chance the Rust heap has shifted.




📋 Practical Task

Exercise: Implementing a Zero-Copy RGB-to-Grayscale Buffer

You are provided with a Rust library that currently implements a grayscale filter by returning a Vec<u8>, causing severe lag in a web-based image editor. Your task is to refactor the implementation to use a shared memory pattern.

Requirements:

  • Modify the Rust code to implement a BufferManager struct that allocates a Vec<u8> once.
  • Implement a method ptr() that returns the raw pointer to the buffer and a method len() that returns its size.
  • Implement the grayscale method to modify the internal buffer in-place rather than returning a new vector.
  • In the provided JavaScript snippet, replace the current const result = wasm.apply_grayscale(data) logic with a Uint8Array view that points directly to the BufferManager's memory.
  • Ensure the JavaScript code handles the "Memory Growth" risk by recreating the view immediately before accessing the processed pixels.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.