jsrosetta

Types

Weak References: WeakRef and FinalizationRegistry

How Node.js's WeakRef and FinalizationRegistry compare to Go 1.24's weak.Pointer and runtime.AddCleanup: holding a reference without blocking GC, cleaning up after an object is reclaimed, and a cache that releases memory on its own.

By
Minimum versions
Node.js ≥ 14.8Go ≥ 1.24
Verified on
Node.js 24.12.0Go 1.27.1

Node.js code is an ES module: save it as .mjs or set "type": "module" in package.json.

A normal reference keeps an object alive: as long as something points to it, the garbage collector (GC) can't reclaim it. A weak reference doesn't — it lets you get the object back while it's still alive, but it doesn't count toward "someone is still using this". JavaScript has had WeakRef (read back with deref()) and FinalizationRegistry (a callback run after an object is reclaimed) since ES2021. Go 1.24 added the two matching pieces: the weak package with weak.Pointer[T] (read back with Value()), and runtime.AddCleanup — the modern replacement for runtime.SetFinalizer. Neither side promises when the GC runs, so the examples below force a collection (globalThis.gc() / runtime.GC()) to keep their output repeatable.

Weak references: WeakRef / weak.Pointer

// run: node --expose-gc weakref.mjs
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
 
let user = { name: "neko" };
const ref = new WeakRef(user); // doesn't keep user alive
 
console.log(ref.deref()?.name); // → neko
 
user = null; // drop the last strong reference
await tick(); // let the sync code and the microtask queue finish: only now may the WeakRef let go of its target
globalThis.gc(); // force a GC, only available with --expose-gc
 
console.log(ref.deref()); // → undefined

Cleaning up after reclamation: FinalizationRegistry / runtime.AddCleanup

// run: node --expose-gc registry.mjs
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
 
const registry = new FinalizationRegistry((heldValue) => {
  console.log("cleaning up", heldValue); // receives heldValue, NOT the reclaimed object
});
 
let a = { id: 1 };
let b = { id: 2 };
const token = {}; // used to unregister later
registry.register(a, "conn#1");
registry.register(b, "conn#2", token);
 
console.log(registry.unregister(token)); // → true: conn#2 will no longer be cleaned up via the registry
 
a = null;
b = null;
await tick();
globalThis.gc();
await tick(); // the callback runs in a later task after GC, not inside gc() itself
// → cleaning up conn#1

Both APIs have the same shape: you register (object, accompanying value), and when the object is reclaimed the callback receives the accompanying value — never the object itself, since by then it's gone. The accompanying value (heldValue / arg) is held strongly, so it must never point back to the object: registry.register(obj, obj) throws a TypeError, runtime.AddCleanup(p, f, p) panics, while an indirect reference (a Go closure capturing a, or a heldValue that's an object containing obj) raises no error at all — the object simply lives forever and the callback never runs.

In practice: a cache that releases memory on its own

The typical use case: a cache whose values can be reclaimed by the GC once nobody else is using them. The map holds keys normally but holds values through a weak reference; when a value is reclaimed, the cleanup callback also removes its stale key from the map.

// run: node --expose-gc cache.mjs
class WeakCache {
  #refs = new Map(); // key → WeakRef(value): the Map holds the key, not the value
  #registry = new FinalizationRegistry((key) => {
    // only delete if the key still points to an emptied WeakRef: it may have been set to a new value since
    if (this.#refs.get(key)?.deref() === undefined) {
      this.#refs.delete(key);
    }
  });
 
  get(key) {
    return this.#refs.get(key)?.deref();
  }
 
  set(key, value) {
    this.#refs.set(key, new WeakRef(value));
    this.#registry.register(value, key);
  }
 
  get size() {
    return this.#refs.size;
  }
}
 
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
const cache = new WeakCache();
 
let report = { title: "Q3", rows: new Array(1_000_000).fill(0) };
const logo = { title: "logo" };
cache.set("report", report);
cache.set("logo", logo);
 
report = null; // only the cache holds report now, and it holds it weakly
await tick();
globalThis.gc();
 
console.log(cache.get("report")); // → undefined
console.log(cache.get("logo")); // → { title: 'logo' }
 
await tick(); // wait for the FinalizationRegistry callback to delete the 'report' key
console.log(cache.size); // → 1

Reference: go.dev/blog/cleanups-and-weak