JS and Wasm: Two Memory Models, One Runtime
With growing adoption of WebAssembly in performance-sensitive applications, the intersection of JavaScript's automatic memory management and Wasm's manual model is becoming a common pain point. JavaScript developers are accustomed to garbage collection handling allocation and reclamation transparently. WebAssembly, by design, has no built-in garbage collector — it operates as a stack machine over a resizable block of raw bytes called linear memory, which the host environment exposes as an ArrayBuffer.
Wasm code interacts with that memory via 32-bit offsets that act as direct pointers. JavaScript shares the same buffer and typically reads and writes through TypedArrays to shuttle data across the boundary. This split creates a critical tension: JavaScript manages memory implicitly; Wasm expects the developer to allocate and free explicitly. The burden of bridging that gap falls on the host code.
That said, Wasm's isolation is a deliberate security property. Wasm modules access only their own linear memory plus explicitly declared host imports — they cannot reach JavaScript objects directly and communicate only through numeric values, function references, or shared-buffer operations. This sandboxing is especially important in multi-tenant environments like Cloudflare Workers, where a misbehaving module must not interfere with neighbors.

Low-level interop between Wasm memory and JavaScript types usually means writing glue code to interpret raw byte arrays into usable JavaScript structures — an exercise that quickly becomes repetitive and error-prone. Tooling such as wasm-bindgen for Rust and Emscripten (Embind) for C/C++ automates that boilerplate. Cloudflare notes that it uses these same libraries under the hood in its own stacks — wasm-bindgen in workers-rs and Emscripten in Python Workers — precisely to eliminate hand-written interop layers.
Why Manual Cleanup Is Easy to Get Wrong
The typical flow is illustrative. A Rust-compiled Wasm function allocating a string and returning a raw pointer to JavaScript must call something like forget on the allocation so that it survives the function return. A second exported function, conventionally named free_buffer, then reclaims that memory when the caller is finished.
// Allocate a fresh byte buffer and hand the raw pointer + length to JS.
// *We intentionally “forget” the Vec so Rust will not free it right away;
// JS now owns it and must call `free_buffer` later.*
#[no_mangle]
pub extern "C" fn make_buffer(out_len: *mut usize) -> *mut u8 {
let mut data = b"Hello from Rust".to_vec();
let ptr = data.as_mut_ptr();
let len = data.len();
unsafe { *out_len = len };
std::mem::forget(data);
return ptr;
}
/// Counterpart that **must** be called by JS to avoid a leak.
#[no_mangle]
pub unsafe extern "C" fn free_buffer(ptr: *mut u8, len: usize) {
let _ = Vec::from_raw_parts(ptr, len, len);
}
In JavaScript, every call into such Wasm code becomes an ownership event. The application must invoke the free function at the right moment — not too early, not too late, and never twice. Even a straightforward console.log across the Wasm boundary requires care because WebAssembly itself has no access to Web APIs; the JavaScript side is the only pathway to the outer world. Leaving allocated Wasm memory unreleased leads to quiet, compounding leaks that degrade performance over time, especially under the memory limits of a serverless worker. A single missed free in a long-lived handler can be the difference between a stable service and repeated out-of-memory restarts.
The FinalizationRegistry Promise and Its Catch
Introduced via the TC-39 WeakRef proposal, FinalizationRegistry lets a program register an object and receive a callback when that object is garbage-collected. On the surface, it offers a convenient route to automating Wasm cleanup: register objects borrowed from Wasm with a finalizer that invokes the matching free_* function. The pattern shifts responsibility for cleanup away from the application developer and onto the garbage collector. wasm-bindgen follows exactly that approach.
const my_registry = new FinalizationRegistry((obj) => { console.log("Cleaned up: " + obj); });
{
let temporary = { key: "value" };
// Register this object in our FinalizationRegistry -- the second argument,
// "temporary", will be passed to our callback as its obj parameter
my_registry.register(temporary, "temporary");
}
// At some point in the future when temporary object gets garbage collected, we'll see "Cleaned up: temporary" in our logs.
Yet the same API is also a trap. GC is nondeterministic. Callbacks may run "then, or some time later, or not at all," as the specification documentation itself states. A conforming JavaScript engine is under no obligation to fire them. Emscripten's docs strike the same note, warning that finalizers have no guarantees about timing or execution order, making them "unsuitable for general RAII-style resource management." Treat a FinalizationRegistry as a last-resort backstop, the article argues, never as a substitute for explicit deterministic teardown where an object's lifecycle is known.
"A conforming JavaScript implementation, even one that does garbage collection, is not required to call cleanup callbacks. When and whether it does so is entirely down to the implementation of the JavaScript engine."
Because Wasm memory is not itself a JavaScript object, the registry's granularity is wrong for this task: it observes the wrapper or proxy object you registered, not the underlying native allocation. If the wrapper survives but the Wasm memory is needed elsewhere — or vice versa — the finalizer either fires far too late or never at all. This is why the clear advice is to avoid direct use of FinalizationRegistry in most application code. Just because Cloudflare added support for it in Workers does not mean it's good practice to reach for it.
Bringing FinalizationRegistry to Workers
Cloudflare initially kept FinalizationRegistry disabled in the Workers runtime, largely because of its non-deterministic behavior. But demand grew as Wasm-based Workers became more common, especially among high-traffic customers running massive request volumes. One such customer needed tight memory control to handle sustained traffic spikes, and manual cleanup wasn't always practical or reliable. That pushed Cloudflare to reconsider and work through the trade-offs of enabling the API in a multi-tenant edge runtime.
Safe defaults and cleanup timing
Enabling FinalizationRegistry comes with guardrails to prevent misuse. The most important constraint: cleanup callbacks run without an active async context, so they can't perform any I/O — no fetch requests, no logging metrics, no events to a tail Worker. This is deliberate. Finalizers are meant for cleanup, particularly for releasing WebAssembly memory, not for triggering side effects. Allowing I/O would encourage developers to rely on finalizers for critical logic, and since garbage collection timing is non-deterministic and outside your control, that would invite flaky, hard-to-debug behavior.
While Cloudflare can't control when V8's garbage collector runs, it can influence when finalizer callbacks execute. Like Node.js and Deno, Workers schedules FinalizationRegistry jobs only after the microtask queue has drained, so cleanup batches run in the quiet gaps between I/O phases of the event loop.

Security analysis
The Workers runtime is built to resist side-channel attacks in a shared, multi-tenant environment. Before enabling FinalizationRegistry, Cloudflare's security team assessed whether the API would weaken that model, focusing on two potential attack vectors: the garbage collector as a confused deputy, and the GC as a timing source.
The confused deputy concern — a privileged component tricked into abusing its authority on behalf of untrusted code — was dismissed after analysis. The V8 GC is effectively contained within the runtime, with the V8 Isolate serving as the primary security boundary. Even though FinalizationRegistry exposes some internal GC mechanics, finalizer callbacks execute in the same isolate that registered them, never across isolates.
The second concern was whether FinalizationRegistry could be abused as a high-resolution timer, a common vector for Spectre-style side-channel attacks. In practice, the resolution of such a "GC timer" is low and highly variable, making it unreliable for timing attacks. Additionally, Cloudflare's control over when finalizer callbacks are scheduled — delaying them until after the microtask queue drains — further limits timing precision. After review, the security team concluded that the existing model is sufficient.
Deterministic cleanup with Explicit Resource Management
JavaScript's Explicit Resource Management proposal offers a deterministic alternative for managing resources that need manual cleanup — file handles, connections, database sessions. Drawing from C#'s using and Python's with, it adds using and await using syntax that automatically disposes of objects adhering to a cleanup protocol when they go out of scope.
class MyResource {
[Symbol.dispose]() {
console.log("Resource cleaned up!");
}
use() {
console.log("Using the resource...");
}
}
{
using res = new MyResource();
res.use();
} // When this block ends, Symbol.dispose is called automatically (and deterministically).
The proposal includes finer-grained controls over when disposal runs, but the core value is deterministic resource cleanup. Rewriting the earlier WebAssembly example with this mechanism replaces FinalizationRegistry with explicit disposal calls:
const { instance } = await WebAssembly.instantiate(WasmBytes, {});
const { memory, make_buffer, free_buffer } = instance.exports;
class WasmBuffer {
constructor(ptr, len) {
this.ptr = ptr;
this.len = len;
}
[Symbol.dispose]() {
free_buffer(this.ptr, this.len);
}
}
{
const lenPtr = 0;
const ptr = make_buffer(lenPtr);
const len = new DataView(memory.buffer).getUint32(lenPtr, true);
using buf = new WasmBuffer(ptr, len);
const data = new Uint8Array(memory.buffer, ptr, len);
console.log(new TextDecoder().decode(data)); // → “Hello from Rust”
} // Symbol.dispose or free_buffer gets called deterministically here
Unlike FinalizationRegistry, Explicit Resource Management runs cleanup logic — such as calling free_buffer in via WasmBuffer[Symbol.dispose]() and the using syntax — at a predictable point in the code, rather than waiting on garbage collector timing. For critical resources like memory, that determinism is a significant advantage.
Where things are headed
Ecosystem adoption is already underway. Emscripten uses Explicit Resource Management for Wasm memory with FinalizationRegistry as a fallback, and wasm-bindgen supports it in experimental mode. The proposal was recently conditionally advanced to Stage 4 in TC39, meaning it's on track to become part of the JavaScript standard. Cloudflare added support for it in Workers in May 2025.
FinalizationRegistry isn't going away
Explicit Resource Management doesn't make FinalizationRegistry obsolete. There are still cases where a Wasm-allocated object's lifecycle is outside your control, or where explicit disposal isn't practical — third-party libraries, dynamic lifecycles, and integration layers that don't follow using patterns. In those situations, FinalizationRegistry remains a valuable safety net for preventing memory leaks.
Going forward, a hybrid approach is likely to become standard in Wasm-JavaScript applications: Explicit Resource Management for deterministic cleanup, with FinalizationRegistry as a backup when full control isn't achievable. Together, they provide a more reliable foundation for managing memory across the JavaScript and WebAssembly boundary.



