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.
- 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()); // → undefinedpackage main
import (
"fmt"
"runtime"
"weak"
)
type User struct {
Name string
}
func main() {
user := &User{Name: "gopher"}
wp := weak.Make(user) // weak.Pointer[User]: doesn't keep user alive
fmt.Println(wp.Value().Name) // → gopher
user = nil // only for readability: in Go, user is already dead after its last use
runtime.GC() // force a GC
fmt.Println(wp.Value()) // → <nil>
}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#1package main
import (
"fmt"
"runtime"
)
type Conn struct {
addr string
fd int
}
func main() {
done := make(chan struct{})
a := &Conn{addr: "db:5432", fd: 3}
b := &Conn{addr: "cache:6379", fd: 4}
// the cleanup receives arg (a.fd), not a itself: if the closure referenced a, a would never be reclaimed
runtime.AddCleanup(a, func(fd int) {
fmt.Println("closing fd", fd)
close(done)
}, a.fd)
cb := runtime.AddCleanup(b, func(fd int) {
fmt.Println("closing fd", fd)
}, b.fd)
cb.Stop() // cancel b's cleanup, the equivalent of registry.unregister(token)
runtime.KeepAlive(b) // b must stay reachable across Stop for Stop to be guaranteed to work
a = nil // only for readability: a and b are already dead after their last use
b = nil
runtime.GC()
<-done // the cleanup runs on its own goroutine, after GC: we have to wait for it
// → closing fd 3
}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); // → 1package main
import (
"fmt"
"runtime"
"sync"
"weak"
)
type Cache[K comparable, V any] struct {
mu sync.Mutex // cleanups run on another goroutine, so the map needs a lock
refs map[K]weak.Pointer[V]
}
func NewCache[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{refs: make(map[K]weak.Pointer[V])}
}
func (c *Cache[K, V]) Get(key K) *V {
c.mu.Lock()
defer c.mu.Unlock()
return c.refs[key].Value() // missing key: zero-value weak.Pointer, Value() returns nil
}
func (c *Cache[K, V]) Set(key K, value *V) {
wp := weak.Make(value)
c.mu.Lock()
c.refs[key] = wp
c.mu.Unlock()
runtime.AddCleanup(value, func(key K) {
c.mu.Lock()
defer c.mu.Unlock()
if c.refs[key] == wp { // only delete if the key hasn't been Set to a new value since
delete(c.refs, key)
}
}, key)
}
func (c *Cache[K, V]) Len() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.refs)
}
type Doc struct {
Title string
Rows []int
}
func main() {
cache := NewCache[string, Doc]()
report := &Doc{Title: "Q3", Rows: make([]int, 1_000_000)}
logo := &Doc{Title: "logo"}
cache.Set("report", report)
cache.Set("logo", logo)
report = nil // only for readability: report is already dead after cache.Set; only the cache holds it, weakly
runtime.GC()
fmt.Println(cache.Get("report")) // → <nil>
fmt.Println(cache.Get("logo")) // → &{logo []}
for cache.Len() > 1 { // wait for the cleanup to delete the "report" key (it runs on its own goroutine)
runtime.Gosched()
}
fmt.Println(cache.Len()) // → 1
runtime.KeepAlive(logo) // keep logo alive until here
}Reference: go.dev/blog/cleanups-and-weak