JH

POST № 001 · 2026

Five Wasm Memory Rules

Lessons learned from shipping Wasm apps

I’ve been writing web-based creative tools for over 10 years. I started using WebAssembly in 2020, when I needed the performance and deep computer graphics library ecosystem provided by native languages. I’m usually teaching myself when any new technology comes out, and, for WebAssembly, managing and interfacing memory between the JavaScript and WebAssembly environments was the steepest learning curve.

Since then, I’ve shipped six production apps with custom Wasm libraries using a mix of C++ and Rust, and I’ve settled on a few simple rules and design patterns for using Wasm memory. The underlying principles are language-agnostic; the Wasm memory model doesn’t care what compiled to it.

The Five Rules

  1. Contain your Wasm objects. Wasm memory isn’t garbage collected, so it needs a boundary layer.
  2. Always use try/finally. It’s the only deterministic cleanup mechanism you have.
  3. Minimize boundary crossings. Copying data between JS and Wasm is expensive.
  4. Copy views immediately. Views into Wasm memory are live pointers that can be invalidated at any time.
  5. Let Wasm pull, not JS push. For large or external data, let Wasm drive the data flow.

Rule 1: Contain Your Wasm Objects

The Wasm Heap Lives Inside JavaScript

Wasm doesn’t have its own memory system running alongside JavaScript. The Wasm heap is exposed to JavaScript via WebAssembly.Memory, whose underlying storage is an ArrayBuffer (or SharedArrayBuffer in the threaded case). TypedArrays like Uint8Array are just views into that buffer. When you instantiate a Wasm module, the runtime creates a chunk of linear memory, and JavaScript holds the backing store.

The relationship is asymmetric:

  • JavaScript can see Wasm linear memory. It’s just a buffer. You can create a TypedArray view over it and read, write, or inspect it byte by byte.
  • Wasm cannot directly access JavaScript’s garbage-collected heap. Wasm has no concept of JS objects, strings, or GC’d values. It can only read and write its own linear memory.

This asymmetry is the root cause of almost every Wasm memory management problem.

Wasm Memory Isn’t Garbage Collected

Wasm allocations don’t participate in JavaScript garbage collection. The GC operates on discrete JavaScript objects — it can trace references, identify what’s reachable, and reclaim what isn’t. But from JS’s perspective, Wasm linear memory is one big ArrayBuffer. The GC has no visibility into the internal structure of that buffer. It can’t know which byte ranges are in use and which aren’t. It can’t free a sub-region of a buffer.

When you allocate something on the Wasm heap, it stays allocated until you explicitly free it.

The Containment Principle

Keep Wasm objects confined to a small, well-defined part of your JavaScript codebase.

If Wasm pointers and heap-allocated data get passed around freely — stored in component state, cached in a closure — you will lose track of them. A lost Wasm allocation is a permanent leak. The memory is gone until the module is torn down.

The pattern that works: treat Wasm interactions like a database connection. A thin layer manages pointers and exposes clean JavaScript-native data to the rest of your app. Pointers don’t escape that layer.

The rest of this rule is the toolkit for building that containment layer: the mechanisms for moving data across the boundary without letting Wasm objects escape.

Primitives Are the Easy Case

Passing primitive values across the Wasm boundary through function arguments and return values is straightforward. Numbers go in, numbers come out. Wasm natively understands i32, i64, f32, f64, and these map cleanly to JavaScript numbers (with BigInt for i64).

const result: number = WasmInstance.exports.add(2, 3);
const distance: number = WasmInstance.exports.euclideanDistance(x1, y1, x2, y2);

No manual allocation, copying, or cleanup. The values are passed directly as arguments, the function runs, a value comes back. The problems start when you need to pass anything bigger than a number.

Bulk Data: Malloc, Copy, Call, Copy, Free

For large or structured binary data — images, audio buffers, geometry, point clouds — you need to move bulk data across the boundary. The pattern is explicit and manual.

JS → Wasm: Allocate space on the Wasm heap, copy your data in, then pass the pointer as a function argument.

type WasmPointer = null | number;

function processImage(
  WasmInstance: WebAssembly.Instance,
  imageData: Uint8Array,
  width: number,
  height: number,
  channels: number
): Uint8Array {
  const exports = WasmInstance.exports as WasmExports;
  const memory = exports.memory as WebAssembly.Memory;

  let inputPtr: WasmPointer = null;
  let outputPtr: WasmPointer = null;

  try {
    inputPtr = exports.malloc(imageData.byteLength);

    // malloc() returns 0 on failure
    if (inputPtr === 0) {
      throw new Error('inputPtr wasm malloc failed');
    }
   
    // Copy image data from JS into Wasm memory
    const WasmHeap = new Uint8Array(memory.buffer);
    WasmHeap.set(imageData, inputPtr);

    // Call the Wasm function with the pointer and image dimensions
    outputPtr = exports.processImage(inputPtr, width, height, channels);

    // Assume processImage() returns 0 on error
    if (outputPtr === 0){
      throw new Error('outputPtr error');
    }

    // Copy result back to JS — .slice() creates a JS-owned copy
    const outputSize = width * height * channels;
    const result = new Uint8Array(memory.buffer, outputPtr, outputSize).slice();

    return result;
  } finally {
    if(inputPtr !== null){
      exports.free(inputPtr);
    }
    if(outputPtr !== null){
      exports.free(outputPtr);
    }
  }
}

The try/finally block is mandatory — I’ll come back to why in Rule 2.

On the C++ side, the pointer you passed is just an offset into linear memory. There are several ways to receive a pointer from JS. If you declare your function as extern "C", Emscripten conveniently casts JS numbers as uint8_t* so you can use it directly.

extern "C" {
  uint8_t* processImage(uint8_t* inputPtr, int width, int height, int channels) {
    cv::Mat image(height, width, CV_8UC(channels), inputPtr); // no cast
    // ...
  }
}

If you’re using Embind, you will receive the JS pointer as uintptr_t and need to reinterpret_cast it to the type you expect.

emscripten::val processImage(uintptr_t inputPtr, int width, int height, int channels) {
  auto* data = reinterpret_cast<uint8_t*>(inputPtr);
  cv::Mat image(height, width, CV_8UC(channels), data);
  // ...
}

Both receive the identical thing on the wire. The JS side passes a plain number — the byte offset into linear memory — in both cases. What differs is the binding layer sitting between JS and your function.

extern "C" exports go through the raw C ABI, where a pointer is just an i32 on wasm32. JS hands over a number, the ABI hands your function a pointer, and the two are the same bits. Declaring the parameter as uint8_t* and using it directly is correct — no conversion happens because none is needed.

Embind is typed. Its marshaling layer needs a registered conversion for every parameter type, and it has no built-in rule for raw pointers to unregistered types — raw-pointer support is really there for bound C++ classes, and even that needs an explicit policy. So the established convention is to pass the address as an integer Embind already understands and reinterpret_cast it inside the function. The cast isn’t ceremony; it’s the bridge where Embind’s typed world hands off to a raw address.

One detail that matters more than it looks: use uintptr_t, not int, for the integer convention. On wasm32 both are 32 bits, so int works and you may never notice. But uintptr_t is defined as pointer-width, so it’s the one that survives a move to Memory64 — where pointers become 64-bit and int would silently truncate the address. This is an easy way to future-proof your code if you ever target 64-bit address space.

Continuing with the above example for image data, we can wrap the buffer we received from JS in a cv::Mat:

extern "C" {
  uint8_t* processImage(uint8_t* inputPtr, int width, int height, int channels) {
    // inputPtr is already a pointer into wasm linear memory — emscripten maps
    // the JS-side number to a pointer for extern "C" params. No cast needed.
    cv::Mat image(height, width, CV_8UC(channels), inputPtr);

    cv::Mat result;
    cv::GaussianBlur(image, result, cv::Size(5, 5), 0);

    size_t outputSize = result.total() * result.elemSize();
    uint8_t* output = static_cast<uint8_t*>(malloc(outputSize));
    std::memcpy(output, result.data, outputSize);

    return output;
  }
}

The cv::Mat wraps the pointer directly — it doesn’t copy the pixel data. Efficient, but the Mat is only valid as long as that region of the Wasm heap is. Don’t store it.

Returning Data with Embind’s typed_memory_view

If you’re using Embind, typed_memory_view is a cleaner option for returning binary data. Instead of returning a raw pointer and making JS figure out the size, you return a view that JS receives as a TypedArray pointing directly into Wasm memory.

#include <emscripten/bind.h>
#include <emscripten/val.h>

// Store result in a persistent buffer so the pointer survives the return
static std::vector<uint8_t> resultBuffer;

emscripten::val processImage(uintptr_t inputPtr, int width, int height, int channels) {
  auto* data = reinterpret_cast<uint8_t*>(inputPtr);
  cv::Mat image(height, width, CV_8UC(channels), data);

  cv::Mat result;
  cv::GaussianBlur(image, result, cv::Size(5, 5), 0);

  resultBuffer.assign(result.data, result.data + result.total() * result.elemSize());

  return emscripten::typed_memory_view(resultBuffer.size(), resultBuffer.data());
}

EMSCRIPTEN_BINDINGS(image_module) {
  emscripten::function("processImage", &processImage);
}

On the JS side, this is much more ergonomic:

const resultView: Uint8Array = module.processImage(inputPtr, width, height, 4);
const result = resultView.slice(); // Critical — see Rule 4

A note on the static std::vector: this works because main-thread Wasm is single-threaded. JS calls into Wasm, Wasm writes to the buffer, returns the view, and JS copies it out — all synchronously. With Wasm threads via SharedArrayBuffer and pthreads, a static buffer is a data race waiting to happen. In that case, use per-call allocation or a thread-local buffer.

The Same Principles in Rust with Wasm-bindgen

The tooling is different but the underlying mechanics are identical. The Wasm heap is still a TypedArray. You still need to copy data across the boundary. You still need to clean up.

Wasm-bindgen handles a lot of the ceremony for you. When you write a function that takes a &[u8], the generated JS glue code automatically calls malloc in the Wasm heap, copies the data in, calls your function, and frees the buffer afterward:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn process_image(data: &[u8], width: u32, height: u32) -> Vec<u8> {
    // `data` is already a valid Rust slice pointing into Wasm linear memory.
    // Wasm-bindgen copied the JS Uint8Array into the Wasm heap for you.
    
    let mut result = data.to_vec();
    // ... process pixels ...
    
    result
    // Returning Vec<u8> — Wasm-bindgen copies this back to JS as a Uint8Array
    // and Rust's allocator frees the Vec when it's dropped.
}

Ergonomic, but understand what’s happening underneath: the generated glue is doing exactly the malloc-copy-call-copy-free dance from the C++ section. For a &[u8] parameter, it allocates Wasm memory, copies the JS array in, passes the pointer and length, and frees after the call. For a Vec<u8> return, it copies the bytes out to a new JS Uint8Array and lets Rust drop the Vec. Same copy costs, just hidden.

When you need to avoid the copy — returning a view into Wasm memory the same way Embind’s typed_memory_view does — use js_sys::Uint8Array::view:

use js_sys::Uint8Array;
use std::cell::RefCell;
use wasm_bindgen::prelude::*;

thread_local! {
    static RESULT_BUFFER: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}

#[wasm_bindgen]
pub fn process_image_view(data: &[u8], width: u32, height: u32) -> Uint8Array {
    let result = process_pixels(data, width, height);

    RESULT_BUFFER.with_borrow_mut(|buf| {
        *buf = result;
        // SAFETY: `Uint8Array::view` aliases wasm linear memory and is only
        // valid until the next allocation or `memory.grow()`. The JS caller
        // must copy it out immediately (Rule 4). The bytes live in the
        // thread-local, so they outlive this borrow.
        unsafe { Uint8Array::view(buf.as_slice()) }
    })
}

The Rust ownership model gives you some protection against use-after-free within Rust code, but the JS/Wasm boundary is where that protection ends. Once you hand a view to JS, Rust can’t track what happens to it.

Structured Data with Embind

Raw buffers aren’t always what you need. Sometimes you want structured data — a bounding box with named fields, a list of detected features, configuration that goes both directions. Embind can expose C++ structs, classes, and std::vector types directly to JavaScript:

struct BoundingBox {
  int x, y, width, height;
  float confidence;
};

std::vector<BoundingBox> detectObjects(uintptr_t inputPtr, int width, int height) {
  // ... detection logic ...
  return results;
}

EMSCRIPTEN_BINDINGS(detection_module) {
  emscripten::value_object<BoundingBox>("BoundingBox")
    .field("x", &BoundingBox::x)
    .field("y", &BoundingBox::y)
    .field("width", &BoundingBox::width)
    .field("height", &BoundingBox::height)
    .field("confidence", &BoundingBox::confidence);

  emscripten::register_vector<BoundingBox>("VectorBoundingBox");
  emscripten::function("detectObjects", &detectObjects);
}

On the JS side, this feels almost native:

const boxes = module.detectObjects(inputPtr, width, height);
try {
  for (let i = 0; i < boxes.size(); i++) {
    const box = boxes.get(i);
    console.log(`Found object at (${box.x}, ${box.y}) — ${box.confidence}`);
  }
} finally {
  boxes.delete();
}

These objects live on the Wasm heap. The vector returned by detectObjects is not a JavaScript array — it’s a C++ object with a pointer into linear memory, wrapped in a JS proxy. It won’t be garbage collected. You must call .delete() to free it.

Structs in Wasm-bindgen

Rust structs work differently. When you annotate a struct with #[wasm_bindgen], it gets exposed to JS as an opaque handle by default. You need to explicitly expose fields or methods:

#[wasm_bindgen]
pub struct BoundingBox {
    pub x: i32,
    pub y: i32,
    pub width: i32,
    pub height: i32,
    pub confidence: f32,
}

#[wasm_bindgen]
pub fn detect_objects(data: &[u8], width: u32, height: u32) -> Vec<BoundingBox> {
    // ... detection logic ...
    results
}

The critical difference from Embind: Wasm-bindgen can automatically free some objects when their JS wrappers are garbage collected, using FinalizationRegistry under the hood. But this is nondeterministic — there’s no guarantee when finalizers run, and environment support varies. For performance-sensitive code, or any code that creates Wasm objects in a tight loop, you should still call .free() explicitly. If you let cleanup rely on the GC, you’ll see memory pressure build up because the finalizers may not run frequently enough to keep up.

Managing Many Objects Part 1: The Singleton Tracker

When a single unit of work creates multiple Wasm objects — intermediate geometries, sub-meshes, transformation results — tracking each one individually with its own try/finally gets unwieldy. A singleton tracker lets you register objects as you create them and clean them all up at once. (This assumes one synchronous unit of work at a time. Because cleanup() clears the whole registry, don’t nest tracked operations or await mid-scope — an inner cleanup() would delete the outer’s objects.)

interface DeletableWasmObject {
  delete(): void;
}

class WasmObjectTracker {
  private static instance: WasmObjectTracker | null = null;
  private registry = new Set<DeletableWasmObject>();

  private constructor() {}

  private static getInstance(): WasmObjectTracker {
    if (!WasmObjectTracker.instance) {
      WasmObjectTracker.instance = new WasmObjectTracker();
    }
    return WasmObjectTracker.instance;
  }

  /** Register a Wasm object for tracking. Returns the object for chaining. */
  static track<T extends DeletableWasmObject>(obj: T): T {
    WasmObjectTracker.getInstance().registry.add(obj);
    return obj;
  }

  /** Delete a single object and remove it from the registry. */
  static release(obj: DeletableWasmObject): void {
    try {
      obj.delete();
    } catch (e) {
      console.warn('Error deleting Wasm object:', e);
    }
    WasmObjectTracker.getInstance().registry.delete(obj);
  }

  /** Delete all tracked objects. Call this when a unit of work is complete. */
  static cleanup(): void {
    const instance = WasmObjectTracker.getInstance();
    for (const obj of instance.registry) {
      try {
        obj.delete();
      } catch (e) {
        console.warn('Error deleting Wasm object:', e);
      }
    }
    instance.registry.clear();
  }
}

Usage wraps a complex operation in a single cleanup scope:

function buildGeometry(module: WasmModule): BufferGeometry {
  try {
    const sectionA = WasmObjectTracker.track(module.createCrossSection(polyA));
    const sectionB = WasmObjectTracker.track(module.createCrossSection(polyB));
    const merged = WasmObjectTracker.track(sectionA.union(sectionB));
    const solid = WasmObjectTracker.track(merged.extrude(10));

    // Only the final result gets extracted to JS
    return manifoldToBufferGeometry(solid);
  } finally {
    WasmObjectTracker.cleanup();
  }
}

Every intermediate Wasm object is registered on creation and deleted in the finally, regardless of where an exception occurs. The singleton is the containment layer. Nothing outside it ever touches a Wasm pointer.

Managing Many Objects Part 2: Explicit Scopes

The singleton has an assumption baked in: one unit of work at a time, run start to finish without interruption. cleanup() empties a single global registry, so it can’t tell one unit of work’s objects from another’s.

Two situations break that. The first is nesting — a tracked operation that internally calls another tracked operation. The inner cleanup() fires first and deletes everything, including the outer operation’s objects, which are dangling the moment the outer code touches them. The second is async — the instant you await inside a tracked region, another operation can start, register on the same global registry, and run its own cleanup() out from under you.

The fix isn’t more machinery around the global; it’s getting rid of the global. Make the scope an explicit object and hand it to the function that does the work:

interface DeletableWasmObject {
  delete(): void;
}

class WasmScope {
  private registry = new Set<DeletableWasmObject>();

  track<T extends DeletableWasmObject>(obj: T): T {
    this.registry.add(obj);
    return obj;
  }

  dispose(): void {
    for (const obj of this.registry) {
      try {
        obj.delete();
      } catch (e) {
        console.warn('Error deleting Wasm object:', e);
      }
    }
    this.registry.clear();
  }
}

function withWasmScope<T>(fn: (scope: WasmScope) => T): T {
  const scope = new WasmScope();
  try {
    return fn(scope);
  } finally {
    scope.dispose();
  }
}

Now each unit of work owns its own registry. Nested scopes don’t see each other, concurrent operations don’t share state, and — the part that matters even if you never nest — the scope is a visible argument instead of an ambient lookup. With the singleton, track() reaches for a global and you have to know that’s happening; with the explicit version, you can read at the call site exactly which cleanup boundary an object belongs to. The coupling stops being something you remember and becomes something you see.

The cost is a two-fold: you need to pass the scope through the code that needs it, and you need to make sure all your functions are storing their long-lived Wasm objects in the scope.

If your Wasm work is always a single, synchronous, self-contained operation, then the singleton is simpler and there’s no reason to reach further. But if you find yourself nesting cleanup boundaries or awaiting inside one, move to explicit scopes before the singleton registry quietly deletes something it shouldn’t.


Rule 2: Always Use try/finally

You’ve seen try/finally in every code example in Rule 1. Here’s why it’s non-negotiable.

If anything between malloc and free throws, the pointer is lost and the memory leaks permanently. No GC catches it. No destructor runs. The bytes sit allocated in the Wasm heap until the module instance is destroyed. There is no fallback mechanism — try/finally is the only deterministic cleanup Wasm gives you.

The pattern is always the same:

const ptr = exports.malloc(size);
try {
  // All work with the pointer happens here
} finally {
  exports.free(ptr);
}

When you have multiple pointers, track them in an array:

const pointers: number[] = [];
try {
  const ptrA = exports.malloc(sizeA);
  pointers.push(ptrA);

  const ptrB = exports.malloc(sizeB);
  pointers.push(ptrB);

  // Work with both pointers...
} finally {
  for (const ptr of pointers) {
    exports.free(ptr);
  }
}

For Embind objects, the cleanup call is .delete() instead of free(), but the pattern is identical. The singleton tracker from Rule 1 is the same idea scaled up — WasmObjectTracker.cleanup() in a finally block releases an arbitrary number of objects.

Don’t Reach for FinalizationRegistry

You might think FinalizationRegistry could help — register a callback to free Wasm memory when the JS wrapper object gets collected. MDN itself warns that cleanup callbacks are not guaranteed to run, and the spec gives engines wide discretion on timing. Finalizers are nondeterministic: they might run promptly, they might run much later, and in some cases they might not run at all before the page unloads. Building a memory management strategy on this will lead to memory pressure and crashes.

This is also why Wasm-bindgen’s automatic cleanup (which uses FinalizationRegistry internally) isn’t sufficient on its own for performance-sensitive code. try/finally gives you deterministic cleanup. Use that.


Rule 3: Minimize Boundary Crossings

Most meaningful data movement across the JS/Wasm boundary involves a full copy. memcpy on the way in, .slice() on the way out. There are exceptions — primitives pass directly (see Rule 1), and typed_memory_view / Uint8Array::view return zero-copy views into Wasm memory (with the caveats in Rule 4). But for the common case of handing JS-owned bulk data to Wasm or reading Wasm output back to JS-owned storage, you’re copying.

For a single heavy operation — decode an image, run a blur, get the result — the copy overhead is noise compared to the actual work. But that’s not how real applications evolve. What starts as one Wasm call turns into a pipeline: load an image, resize it, apply a color transform, sharpen, composite with another layer, export. If every step round-trips data through the boundary — copy into Wasm, process, copy back to JS, copy into Wasm for the next step — you’re paying the copy tax on every link in the chain. For large buffers, this overhead can dwarf the compute savings, and you end up slower than a pure JavaScript implementation that keeps everything in the GC’d heap.

The Handle Pattern: Load Once, Operate by ID

The fix is to stop moving data and start moving references. Load data into Wasm memory once, give it an ID, and let JS hold onto that ID rather than the data itself. All subsequent operations take the ID as an argument and work on the data in-place on the Wasm heap. Copy data back to JS only at the end, when you actually need it.

On the C++ side, a simple registry:

#include <unordered_map>

static std::unordered_map<int, std::vector<uint8_t>> bufferRegistry;
static int nextId = 1;

// Load data and return a handle
int loadBuffer(uint8_t* data, int size) {
  int id = nextId++;
  bufferRegistry[id] = std::vector<uint8_t>(data, data + size);
  return id;
}

// Operate on data by handle — no boundary crossing
void applyBlur(int bufferId, int width, int height, int channels) {
  auto& buf = bufferRegistry.at(bufferId);
  cv::Mat image(height, width, CV_8UC(channels), buf.data());
  cv::GaussianBlur(image, image, cv::Size(5, 5), 0);
}

void applyResize(int bufferId, int newWidth, int newHeight, int channels) {
  auto& buf = bufferRegistry.at(bufferId);
  // ... resize in-place or replace the buffer entry
}

// Only copy out when JS actually needs the pixels
emscripten::val getBuffer(int bufferId) {
  auto& buf = bufferRegistry.at(bufferId);
  return emscripten::typed_memory_view(buf.size(), buf.data());
}

// Release when done
void releaseBuffer(int bufferId) {
  bufferRegistry.erase(bufferId);
}

The JS side becomes a sequence of cheap function calls passing integers:

type WasmPointer = number | null;

function processImagePipeline(
  module: WasmModule,
  imageData: Uint8Array,
  width: number,
  height: number
): Uint8Array {
  let inputPtr: WasmPointer = null;
  let handle: WasmPointer = null;
  try {
    inputPtr = module.malloc(imageData.byteLength);

    // malloc() returns 0 on failure
    if (inputPtr === 0) {
      throw new Error('wasm malloc failed');
    }
    new Uint8Array(module.memory.buffer).set(imageData, inputPtr);
    handle = module.loadBuffer(inputPtr, imageData.byteLength);

    // Optional - free inputPtr for efficiency, or let the finally take care of it
    module.free(inputPtr);
    inputPtr = null;

    // All operations pass the handle — no data crosses the boundary
    module.applyBlur(handle, width, height, 4);
    module.applyResize(handle, width / 2, height / 2, 4);
    module.applySharpen(handle, width / 2, height / 2, 4);

    // One copy out at the end
    const resultView: Uint8Array = module.getBuffer(handle);
    return resultView.slice();
  } finally {
    if(inputPtr !== null){
      module.free(inputPtr);
    }
    if(handle !== null ){
      module.releaseBuffer(handle);
    }
  }
}

Three operations, two boundary crossings instead of six. For longer pipelines the savings compound fast.

The handle registry doesn’t have to live on the Wasm side. You can keep it in JavaScript instead, holding raw ArrayBuffer references and copying into Wasm only for the operations that need it. The right choice depends on where the data spends most of its time. If most operations are Wasm-side, keep it in Wasm. If JS needs frequent read access, keep it in JS and push to Wasm selectively.

Either way, the principle is the same: cross the boundary as few times as possible, and make the thing that lives on each side a managed, named resource — not a raw pointer drifting through your codebase.


Rule 4: Copy Views Immediately

Several of the patterns in Rule 1 return views into Wasm memory rather than copies — Embind’s typed_memory_view, js_sys::Uint8Array::view, and raw Uint8Array slices of memory.buffer. These are fast because there’s no copy. They’re also dangerous because they’re live pointers into the Wasm heap.

Any allocation on the Wasm side — another malloc, a Vec::push that grows past capacity, memory pressure that triggers growth — can lead to a memory.grow() call. When that happens on non-shared memory, the ArrayBuffer backing memory.buffer is replaced entirely, and any TypedArray view into the old buffer is detached. Reading from a detached view gets you either garbage or a thrown exception, depending on the engine. (With SharedArrayBuffer, behavior differs — the buffer isn’t detached on growth — but most single-threaded Wasm uses non-shared memory.)

The rule: call .slice() immediately to get a JS-owned copy, before any other Wasm call.

// WRONG — stores a view that can be invalidated by later Wasm calls
const view = module.processImage(inputPtr, width, height, 4);
doSomethingElseWithWasm(); // might invalidate view
use(view); // reads garbage or throws

// RIGHT — copy out before any other Wasm activity
const view = module.processImage(inputPtr, width, height, 4);
const result = view.slice();
doSomethingElseWithWasm(); // safe — result is JS-owned
use(result);

Same rule for Rust:

const view = module.process_image_view(imageData, width, height);
const result = view.slice(); // Copy to safety before doing anything else

What Survives Heap Growth

Not everything gets invalidated when the heap grows. The distinction matters for deciding what’s safe to hold across Wasm calls:

  • TypedArray views (including typed_memory_view, Uint8Array::view, and any new Uint8Array(memory.buffer, ...)) — invalidated on growth. The backing ArrayBuffer is replaced.
  • Integer handles (like the buffer IDs from the handle pattern in Rule 3) — survive growth. They’re just numbers; the Wasm-side registry looks up the actual pointer at call time.
  • Embind-wrapped objectssurvive growth. They’re JS proxies that reference underlying Wasm-side allocations rather than direct memory views.
  • Wasm-bindgen structssurvive growth. Same principle — they’re handles into a managed wrapper layer, not direct pointers.

This is another reason to prefer handles over views for anything that needs to persist across calls. Views are for “extract this data right now.” Handles are for “refer to this data over time.”


Rule 5: Let Wasm Pull, Not JS Push

Emscripten’s EM_JS macro lets you embed JavaScript code as a string literal inside C++ source. The Wasm module dispatches it to run in the JS environment.

#include <emscripten.h>

// This JavaScript code is embedded in the C++ source and runs in the JS environment
EM_JS(int, getCanvasWidth, (), {
  return document.getElementById('canvas').width;
});

I initially dismissed this as a joke. Why would anyone need to embed JavaScript code in their C++ source? Then I read how Figma uses it and realized it solves a real architectural problem.

The Problem: Wasm Memory Is Finite, JS Memory Isn’t

Everything in the previous rules assumes you can fit your data into the Wasm heap. But Wasm linear memory is capped at 4GB, and in practice you’ll hit pressure well before that. JavaScript’s garbage-collected heap can grow much larger. If your application deals with many large assets — images, geometry buffers, serialized document data — you can’t necessarily fit them all in Wasm memory at once.

The naive answer is “keep everything in JS and copy it into Wasm when needed.” But Rule 3 covers why repeatedly copying large buffers across the boundary is expensive. And the data flow is backwards: JS has to know when Wasm needs data, which means Wasm can’t drive its own control flow.

Letting Wasm Pull

The fix is to flip the relationship. Store the full data in JavaScript. Use EM_JS to expose small stub functions that let C++ code reach into JS memory on demand and copy just the slice it needs into a Wasm-side working buffer.

On the C++ side, define JS stubs that read from an external store:

// These functions execute in JS, but are callable from C++ like normal functions
EM_JS(int, indirect_buffer_length, (int bufferHandle), {
  return Module.indirectBuffers[bufferHandle].byteLength;
});

// `destPtr` arrives in JS as the numeric byte offset into HEAPU8.
EM_JS(void, indirect_buffer_read, (int bufferHandle, int srcOffset, uint8_t* destPtr, int length), {
  const source = new Uint8Array(Module.indirectBuffers[bufferHandle], srcOffset, length);
  HEAPU8.set(source, destPtr);
});

Now C++ can pull data on demand without the JS side orchestrating anything:

void processLargeAsset(int bufferHandle) {
  size_t totalSize = indirect_buffer_length(bufferHandle);

  const int CHUNK_SIZE = 64 * 1024;
  uint8_t* workBuffer = static_cast<uint8_t*>(malloc(CHUNK_SIZE));

  for (int offset = 0; offset < totalSize; offset += CHUNK_SIZE) {
    int chunkLen = std::min(CHUNK_SIZE, totalSize - offset);
    indirect_buffer_read(bufferHandle, offset, workBuffer, chunkLen); // no cast
    processChunk(workBuffer, chunkLen);
  }

  free(workBuffer);
}

On the JS side, registration is trivial:

// Store large buffers in JS memory, hand Wasm an integer handle
Module.indirectBuffers = {};
let nextHandle = 0;

function registerIndirectBuffer(data: ArrayBuffer): number {
  const handle = nextHandle++;
  Module.indirectBuffers[handle] = data;
  return handle;
}

function releaseIndirectBuffer(handle: number): void {
  delete Module.indirectBuffers[handle];
}

Why This Matters

This inverts the data flow. Instead of JS pushing data into Wasm — which requires JS to understand the Wasm module’s internal needs — Wasm pulls what it needs, when it needs it. The C++ code drives its own control flow and only materializes the data it’s currently working on.

Figma uses this pattern to keep image pixels and large serialization buffers in JS or GPU memory while still rendering and processing everything from C++. Their document editor can handle files that would blow past the Wasm 4GB memory limit because the bulk data never needs to live in the Wasm heap all at once. As Evan Wallace described it, they treat it as out-of-heap storage — a way for C++ to reference external typed arrays without those bytes consuming Wasm address space.

The pattern composes well with the handle-based approach from Rule 3. The handle registry lives in JS, the processing logic lives in Wasm, and EM_JS is the bridge that lets Wasm pull data through the boundary on its own terms.


What This Post Doesn’t Cover

WasmGC and Memory64

Wasm 3.0, standardized in late 2025, introduced two features that sound relevant: garbage collection and 64-bit memory addressing.

WasmGC is designed for managed languages like Kotlin, Dart, and Java that need to compile their own GC to Wasm. With WasmGC, those languages can use the browser engine’s native garbage collector instead of shipping their own. If you’re writing C++ or Rust compiled to Wasm, WasmGC doesn’t change your situation. Your allocations are still manual. You still need free(). You still need try/finally.

Memory64 lifts the 4GB linear memory cap by switching to 64-bit addressing. Useful for memory-hungry applications, but engines are currently optimized heavily around 32-bit pointers, so 64-bit mode carries a performance penalty. The current recommendation is to only use it if you actually need more than 4GB.

Threads

Wasm threads with SharedArrayBuffer and atomics fundamentally change the memory model. Shared memory across workers introduces data races, the need for atomic operations, and invalidates assumptions like “a static buffer is safe because everything is synchronous.” Several patterns in this article, such as the static std::vector for typed_memory_view, the singleton tracker, and even the basic try/finally discipline, need to be rethought when multiple threads are in play. That deserves its own post.