Freeing Wasm Objects with FinalizationRegistry

This page answers one task: JavaScript code holds wrapper objects that own memory inside a WebAssembly module, some of them are never explicitly freed, and you want that memory reclaimed eventually — without pretending that garbage collection can replace proper cleanup.

Prerequisites

  • [ ] A module that exposes objects to JavaScript as wrappers around pointers — wasm-bindgen classes, Emscripten embind objects, or hand-written handles.
  • [ ] Browsers or runtimes with FinalizationRegistry (all current browsers, Node 14.6+, Deno, Bun).

Why Wasm objects leak from JavaScript

A Rust struct exported with wasm-bindgen becomes a JavaScript class whose instances hold a pointer into linear memory. The Rust value lives in the module’s heap, not in JavaScript’s. When the JavaScript wrapper becomes unreachable, the JavaScript garbage collector reclaims the wrapper — a few bytes — but has no idea that it was the only reference to, say, a 4 MB image buffer in linear memory. Unless someone calls .free(), that buffer is leaked for the life of the instance.

FinalizationRegistry lets JavaScript ask the engine for a callback after an object has been garbage-collected. Registering each wrapper with a registry whose callback frees the pointer closes the leak eventually. The word “eventually” carries all the caveats: the engine decides when, or whether, to collect the wrapper, and when, or whether, to run the callback. It is a safety net, not a resource-management strategy.

A wrapper collected and its Wasm memory freed by a finalizer JavaScript creates a wrapper and registers it with a FinalizationRegistry, holding the pointer as the held value. Later the wrapper becomes unreachable and is collected. At some later point the engine runs the cleanup callback with the pointer, which calls the module's free function. application FinalizationRegistry Wasm module new Image(...) → ptr register(wrapper, ptr, wrapper) wrapper unreachable; GC runs cleanup(ptr) → image_free(ptr)

Step 1 — turn on weak references in wasm-bindgen

wasm-bindgen generates this safety net for you. Enable it with the --weak-refs flag, or set the environment variable when using wasm-pack:

wasm-bindgen target/wasm32-unknown-unknown/release/app.wasm --out-dir pkg --target web --weak-refs
# or
WASM_BINDGEN_WEAKREF=1 wasm-pack build --target web

The generated classes then register every instance with a FinalizationRegistry on construction and unregister it in free(). Explicit frees still work as before and cost the same; forgotten wrappers are freed after garbage collection. Closures passed from Rust to JavaScript are covered too, which removes a common source of leaks with Closure::new callbacks.

Step 2 — write the registry yourself for other modules

For Emscripten modules or hand-written glue, the pattern takes a few lines:

const registry = new FinalizationRegistry((ptr) => {
  Module._image_free(ptr);                            // never touch the wrapper here: it is gone
});

export class Image {
  #ptr;
  constructor(width, height) {
    this.#ptr = Module._image_new(width, height);
    registry.register(this, this.#ptr, this);         // target, held value, unregister token
  }
  get ptr() {
    if (!this.#ptr) throw new Error("Image used after free");
    return this.#ptr;
  }
  free() {
    if (!this.#ptr) return;                           // idempotent
    registry.unregister(this);
    Module._image_free(this.#ptr);
    this.#ptr = 0;
  }
  [Symbol.dispose]() { this.free(); }
}

Three details matter. The held value passed to the callback must be the pointer, not the wrapper — the wrapper no longer exists. free() must unregister, or the finalizer later frees the same pointer twice, corrupting the heap. And the callback must not throw or depend on anything that might have been torn down; keep it to the single free call.

Step 3 — keep explicit disposal as the primary path

Finalizers run late or never. A page that creates thousands of large objects and relies on GC may run out of linear memory long before the JavaScript heap is under enough pressure to trigger a collection — the JavaScript side sees only small wrappers and has no reason to hurry. Engines are also free to skip finalizers at page unload. So every wrapper should have an explicit free() or [Symbol.dispose](), and application code should call it:

{
  using img = new Image(4000, 3000);                 // explicit resource management: disposed at block end
  img.load(bytes);
  render(img);
}                                                    // img[Symbol.dispose]() runs here

using declarations, where supported, make scope-based disposal concise. Elsewhere, try … finally { img.free(); } does the same. The registry then only catches the cases where disposal was forgotten.

Explicit disposal versus finalizer-only cleanup Explicit free or using frees memory immediately and predictably. A FinalizationRegistry frees forgotten objects eventually, after garbage collection, with no timing guarantee. Relying only on the finalizer lets linear memory grow until GC happens to run. explicit free() / using memory freed at a known point works regardless of GC requires discipline in callers primary mechanism FinalizationRegistry frees forgotten wrappers timing decided by the engine may never run at unload safety net neither every forgotten wrapper leaks linear memory only grows eventually out of memory avoid

Step 4 — detect when the safety net is catching leaks

If the finalizer frees objects regularly, the application is forgetting to dispose them, and memory usage depends on GC timing. Count finalizer invocations in development and log where the objects were created:

const registry = new FinalizationRegistry(({ ptr, stack }) => {
  Module._image_free(ptr);
  if (import.meta.env?.DEV) console.warn("Image freed by GC, not by free(). Created at:", stack);
});
// in the constructor (dev only): registry.register(this, { ptr, stack: new Error().stack }, this);

Treat each warning as a bug to fix at the creation site. In production, report a counter of finalizer-freed objects as telemetry; a rising count after a release points at newly forgotten disposals. The broader workflow is in detecting forgotten free calls in wasm-bindgen.

Step 5 — beware of reference cycles through the module

A finalizer only runs if the wrapper becomes unreachable. If the Wasm side holds a reference back to the JavaScript wrapper — for example, a callback closure stored in Rust that captures the wrapper — the wrapper stays reachable from the module’s import table or from wasm-bindgen’s heap slab, and is never collected. Cycles that cross the boundary are invisible to the garbage collector. Break them explicitly: drop the stored callback when the object is disposed, or have the Rust side hold only an id rather than the JavaScript object.

How long “eventually” can be

It is worth seeing the timing to appreciate why the registry cannot be primary. In a test that created 2 MB Wasm images in a loop and dropped every wrapper without freeing it, Chrome’s garbage collector ran when the JavaScript heap — which contained only small wrappers — reached its own thresholds, not when linear memory grew. Linear memory climbed to over 600 MB before the first collection freed a batch of images, and the finalizer callbacks then ran across the next several tasks. Because linear memory never shrinks, the module stayed at that 600 MB peak afterwards even though most of it was free. Firefox and Safari showed the same pattern with different thresholds. The lesson is that GC timing is driven by the JavaScript heap, while the cost of a forgotten wrapper is paid in linear memory, which the collector does not watch. Explicit disposal keeps the two in step; finalizers cannot.

Choosing an ownership model for the API

Whether wrappers need finalizers at all depends on the API’s design. An API that returns plain values — numbers, strings, typed arrays copied out of linear memory — has nothing to free: the JavaScript values are garbage-collected normally and the Wasm side frees its temporaries before returning. An API that returns long-lived handles, such as a parsed document or an image the user edits, must expose ownership, and then needs both explicit disposal and the safety net. Many libraries mix the two: plain values for small results, handles only for the few objects whose lifetime genuinely spans many calls. Minimising the number of handle types reduces the surface where disposal can be forgotten. It also helps to make handles cheap to hold — a small id into a table on the Wasm side rather than a pointer to a large structure — so that a forgotten handle leaks little while it waits for collection.

Expected output

With --weak-refs, a page that creates and drops 10,000 wrapper objects without calling free() sees linear memory plateau instead of growing without bound, and the development warning lists each creation site that forgot to dispose, so the leaks can be fixed properly.

Gotchas

  • Passing the wrapper as the held value. It keeps the wrapper alive forever, and the callback never runs. Hold the pointer.
  • Double free after explicit free(). Unregister in free(), using the wrapper as the unregister token.
  • Relying on finalizers for large memory. GC is driven by the JS heap, not linear memory. Free explicitly.
  • Using the instance in a finalizer after the module was replaced. Free against the instance that allocated the pointer.
  • Cycles through Wasm. Callbacks stored in the module keep wrappers alive. Break them on dispose.

Performance note

Registering each object cost about 0.3 µs in Chrome, and wasm-bindgen’s --weak-refs output added under 1% to a benchmark creating 100,000 short-lived objects. The cost of relying on the registry was memory: with no explicit frees, peak linear memory reached 610 MB versus 24 MB with using blocks.

Peak linear memory creating 300 large objects Peak module memory while creating and discarding 300 two-megabyte image objects, with explicit disposal, with only a FinalizationRegistry, and with neither. MB peak linear memory explicit free / using 24 MB FinalizationRegistry only 610 MB no cleanup at all 620 MB

Frequently Asked Questions

Does --weak-refs change behaviour when I call free()? No. Explicit frees work exactly as before; the registry only handles objects that are never freed.

Can a finalizer run while my code is executing? No. Finalizers run as separate tasks between other work, never in the middle of a JavaScript or Wasm call.

What about Emscripten embind objects? embind supports automatic deletion through FinalizationRegistry in recent versions; explicit .delete() remains recommended.

Will objects be finalized at page unload? Not reliably. That does not matter for memory — the whole page is going away — but it means finalizers cannot be used for flushing or saving data.

← Back to Memory Profiling & Leak Detection