loke.dev
Header image for A Quiet Hook for the Garbage Collector

A Quiet Hook for the Garbage Collector

Native resource management no longer requires manual reference counting thanks to a long-awaited addition to the JavaScript lifecycle.

· 9 min read

For a long time, I lived in a state of mild paranoia whenever I had to bridge the gap between JavaScript and native resources. I remember a specific Node.js project where we were wrapping a C++ image processing library. We had these beautiful JavaScript objects representing heavy bitmap data stored in system memory. On the JS side, the objects were tiny—just a few bytes of metadata. But in reality, they were tethered to megabytes of unmanaged heap.

I would watch the process memory climb steadily, even after nulling out the JS references. The JavaScript Garbage Collector (GC) would look at my tiny objects, decide they weren't worth the effort to collect yet, and meanwhile, the system would move toward an Out Of Memory (OOM) crash. I ended up implementing a clunky manual .dispose() method on everything, and then spent weeks hunting down "use-after-free" bugs because someone called dispose too early, or memory leaks because they forgot to call it at all.

It felt like JavaScript was missing a fundamental piece of the lifecycle: a way to say, "Hey, when you're done with this object, let me know so I can clean up the mess it left in the basement."

With the arrival of FinalizationRegistry, that missing piece finally exists. It’s a quiet, background hook that allows us to manage native resources without the brittle overhead of manual reference counting.

The Gap Between Managed and Unmanaged

JavaScript is a managed language. We don't malloc or free. We just create objects and trust the engine to sweep them up when they’re no longer reachable. This works perfectly as long as the "cost" of the object is visible to the engine.

The problem arises when a JavaScript object acts as a handle for something the engine *doesn't* see:
1. WASM Memory: Buffers allocated inside a WebAssembly module.
2. File Descriptors: Open handles to the operating system.
3. Graphics Contexts: Textures or buffers sent to the GPU via WebGL or WebGPU.
4. C++ Addons: Objects created in Node.js native modules.

To the GC, a FileHandle object is just a small record with an integer ID. The GC doesn't know that integer ID represents a precious resource that should be closed as soon as possible. Before FinalizationRegistry, our only options were to hope the user called a cleanup method or to use WeakMap—which tells you if an object is still alive, but never tells you the exact moment it dies.

Enter the FinalizationRegistry

The FinalizationRegistry API is deceptively simple. You create a registry, define a cleanup callback, and then register objects with it. When the object is garbage collected, your callback runs.

Here is the basic anatomy:

const registry = new FinalizationRegistry((heldValue) => {
  console.log(`Cleaning up resource: ${heldValue}`);
  // This is where you close the file, free the WASM memory, etc.
});

function createResource() {
  const obj = { name: "HeavyResource" };
  const metadata = "ID_12345";
  
  // Register the object
  registry.register(obj, metadata);
  
  return obj;
}

let myRes = createResource();
myRes = null; // The object is now eligible for GC

In this snippet, obj is the "target"—the thing the GC is watching. metadata is the "held value"—the information passed to the cleanup function.

There is one critical rule you have to follow: The held value cannot be the target itself. If you pass the object you are watching into the cleanup callback, you create a circular reference that prevents the object from ever being garbage collected. It’s like trying to watch yourself leave a room; as long as you’re watching, you’re still in the room.

Why "Quiet" is the Keyword

I call this a "quiet" hook because, unlike almost every other part of JavaScript, it is non-deterministic. You cannot predict exactly when the callback will run.

If you're coming from a language like C++ or Rust, you might be tempted to treat this like a Destructor or a Drop implementation. Don't. In those languages, the cleanup happens the millisecond the object goes out of scope. In JavaScript, the cleanup happens whenever the GC feels like it. If the engine has plenty of memory, it might not run a GC cycle for minutes.

This means FinalizationRegistry is a safety net, not a primary control mechanism. You should still provide a .close() or .dispose() method for users who want to be explicit. The registry is there to catch the leaks when the user forgets.

Practical Example: Managing WASM Memory

WebAssembly is perhaps the biggest benefactor of this feature. When you allocate memory in a WASM linear memory heap, you get back a pointer (a number). If your JS wrapper object gets garbage collected, that pointer is lost, and that memory is leaked until the entire WASM instance is destroyed.

Let’s look at how we can use FinalizationRegistry to automate WASM memory management.

class NativeBuffer {
  constructor(wasmModule, size) {
    this.wasm = wasmModule;
    // Assume the WASM module has an export to allocate memory
    this.pointer = this.wasm.allocate(size);
    this.size = size;

    // Register this instance for cleanup
    // We pass the pointer and the WASM module's free function
    NativeBuffer.registry.register(this, {
      pointer: this.pointer,
      wasm: this.wasm
    });
  }

  // A manual way to free, just in case
  dispose() {
    if (this.pointer !== null) {
      this.wasm.free(this.pointer);
      this.pointer = null;
      // Unregister if manually disposed to avoid double-free
      NativeBuffer.registry.unregister(this);
    }
  }

  static registry = new FinalizationRegistry(({ pointer, wasm }) => {
    console.log(`GC triggered: Freeing WASM pointer ${pointer}`);
    wasm.free(pointer);
  });
}

In this pattern, we provide a dispose() method for the responsible developer, but the static registry acts as the janitor for everyone else. If a NativeBuffer instance falls out of scope, the memory eventually gets returned to the WASM heap.

The Unregister Token

In the example above, I used NativeBuffer.registry.unregister(this). To make this work, we need to pass a third argument to .register().

The unregisterToken is usually the object itself. It allows you to tell the registry: "I’ve handled this manually, you don't need to watch this anymore."

// Improved registration with unregister token
registry.register(target, heldValue, target); 

// Later
registry.unregister(target);

Without the unregister token, your cleanup callback might run even if you've already manually freed the resource, leading to "double-free" errors which are notoriously difficult to debug.

Architectural Considerations: WeakRef + FinalizationRegistry

Usually, FinalizationRegistry doesn't travel alone. It’s often paired with WeakRef. While the registry tells you when an object is *dead*, a WeakRef lets you hold a reference to an object without keeping it *alive*.

Consider a scenario where you have a cache of heavy objects (like database connections). You want to keep using the objects if they exist, but you don't want the cache itself to prevent them from being garbage collected.

class ResourceCache {
  #cache = new Map();
  #registry = new FinalizationRegistry((key) => {
    console.log(`Cache entry ${key} was garbage collected.`);
    this.#cache.delete(key);
  });

  get(key) {
    const ref = this.#cache.get(key);
    if (ref) {
      const obj = ref.deref();
      if (obj) return obj;
    }

    // Not in cache, or was collected
    const newObj = this.#loadResource(key);
    this.#cache.set(key, new WeakRef(newObj));
    this.#registry.register(newObj, key);
    return newObj;
  }

  #loadResource(key) {
    return { id: key, data: new Array(100000).fill('🚀') };
  }
}

Here’s the flow:
1. You request a resource.
2. The cache checks for a WeakRef.
3. If ref.deref() returns the object, great! We saved an allocation.
4. If it returns undefined, the object was GC'd. We create a new one.
5. The FinalizationRegistry ensures our #cache Map doesn't grow forever with empty WeakRef shells. When the object dies, the key is removed from the Map.

This is a much more memory-efficient pattern than a standard Map, which would keep every resource alive forever.

The "Gotchas" and Edge Cases

Every powerful tool has its sharp edges. FinalizationRegistry is no exception.

1. The Callback might never run

If the program exits, the registry callbacks aren't guaranteed to fire. Don't use this to save critical state to a database. If the user hits Ctrl+C or the process crashes, that "held value" logic is gone. It's for memory and resource hygiene, not for transactional integrity.

2. The "Held Value" leak

If your heldValue contains a reference to the target object, or even a reference to the registry itself in some closure scopes, you will leak everything. Keep the heldValue as small and "primitive" as possible. Think: IDs, pointers, file descriptors—not the objects themselves.

3. Stress and Latency

The callback runs on the main thread (in the browser or Node). If your cleanup logic is computationally expensive, it will cause jank. The GC decides when it's time to clean, and it doesn't care if you're in the middle of a high-priority animation frame. Keep cleanup logic fast.

4. Shadowing and Re-registration

If you register the same object multiple times with the same registry, the callback will fire multiple times. This might seem obvious, but in complex architectures where objects are passed through several factory functions, it’s easy to accidentally double-register.

Testing the Untestable

How do you test a feature that relies on the non-deterministic behavior of a garbage collector?

It’s a nightmare. In Node.js, you can start the process with the --expose-gc flag, which allows you to manually trigger a collection using global.gc(). In a browser, it's much harder. Chrome's DevTools has a "trash can" icon to force GC, but you can't easily script that in a unit test.

When writing tests for code using FinalizationRegistry, I usually structure my code so the "Cleanup Logic" is a standalone function I can test in isolation. I then trust the engine to call that function, rather than trying to write a flaky test that waits for the GC to wake up.

// Don't test the GC, test the logic the GC calls
export function cleanupResource(id) {
  // logic to free resource
}

const registry = new FinalizationRegistry(cleanupResource);

A Shift in Responsibility

The introduction of these APIs represents a shift in how we think about the JavaScript lifecycle. For years, we treated JS as a sandbox where we didn't have to care about the "lower levels." But as we push JS into more demanding environments—intensive WASM applications, high-performance Node servers, and complex WebGPU simulations—the abstraction starts to leak.

FinalizationRegistry doesn't make manual resource management go away, but it makes it significantly more robust. It allows us to write libraries that are "well-behaved." It means that when a junior developer uses your high-performance library and forgets to call .shutdown(), they don't accidentally take down the entire production server with a memory leak.

It’s a quiet feature. It’s not flashy like a new syntax sugar or a concurrency primitive. But for those of us who have spent late nights staring at heap snapshots and memory graphs, it’s one of the most welcome additions to the language in years. We finally have a way to close the loop, making sure that when an object says goodbye to the JavaScript world, its ghost doesn't haunt the system memory forever.