WeakRef and FinalizationRegistry
To make sense of WeakRef, start from the idea of reachability covered in the Garbage collection chapter. The engine promises to keep a value in memory as long as that value can still be reached and used. Drop every path to it, and the value becomes eligible for cleanup.
A single reference makes this easy to see:
// the `user` variable holds a strong reference to the object
let user = { name: "Maya" };
// overwrite what `user` points to
user = null;
// nothing reaches the object anymore, so the engine can reclaim it
Add a second reference and the object survives longer:
// `user` holds a strong reference to the object
let user = { name: "Maya" };
// `admin` copies that strong reference — now two variables point at the same object
let admin = user;
// overwrite `user`
user = null;
// the object is still reachable through `admin`, so it stays in memory
The object { name: "Maya" } only becomes collectible once both references are gone — say, after you also set admin = null. Every ordinary reference works this way, and that behavior is exactly what WeakRef opts out of.
WeakRef
A WeakRef is a small wrapper object. It holds one weak reference to another object, which the spec calls the target or the referent.
The point of that wrapper is what it doesn’t do: it never stops the collector from reclaiming the referent. Holding a WeakRef to something does not keep that something alive.
Create one by passing the target object to the WeakRef constructor:
// `user` holds a strong reference to the object
let user = { name: "Maya" };
// `admin` holds a WeakRef — a weak reference to the same object
let admin = new WeakRef(user);
Right now the object has both a strong reference (from user) and a weak one (wrapped inside admin):
Now drop the strong reference — overwrite user, or let it fall out of scope — while keeping the WeakRef in admin:
// remove the last strong reference
user = null;
A weak reference on its own cannot keep the object alive. With no strong references left, the collector may reclaim the object at any moment and reuse its memory. But may is the operative word. Until the collector actually runs and frees it, the weak reference can still hand the object back to you.
That leaves the object in a genuinely uncertain state. From your code’s point of view you cannot know in advance whether a given weak reference still points at a live object or at a slot the collector already emptied — a kind of Schrödinger’s cat situation:
You resolve that uncertainty at the moment you look, using the deref() method. It returns the referent if the object is still in memory, and undefined if the collector has already taken it:
let ref = admin.deref();
if (ref) {
// still alive — safe to read or use the object
} else {
// gone — the garbage collector reclaimed it
}
Try deref() for yourself. Call it while a strong reference still exists and it hands the object back. Drop the strong reference and call it again — in practice it usually still returns the object, because dropping the last strong reference only makes the object eligible for collection; the engine reclaims it whenever it likes, not the instant you ask.
Where WeakRef earns its keep
WeakRef shows up mostly in caches and associative structures that hold expensive objects. The goal is to let something reference a heavy object for reuse without that reference alone being the reason the object stays in memory.
A classic example: you have many large binary image objects (say ArrayBuffer or Blob values) and you want to look each one up by a name or path. The obvious containers do not fit:
- A plain
Mapkeyed by name, with the image as the value, pins every image in memory. As long as the entry sits in theMap, the image cannot be collected. - A
WeakMapdoes not help either. Its keys are weak, but its values are strong — and here the heavy objects are the values, not the keys. (AWeakMapalso requires object keys, whereas you want string names.)
What you actually want is a structure whose values are held weakly. You build that by hand: a regular Map from names to WeakRef instances, where each WeakRef wraps a heavy object.
With that layout, a lookup either finds a still-live image and reuses it, or finds an emptied slot and re-fetches. The heavy objects never outstay their welcome, and in the good case you skip a re-download.
Example 1: a weak cache
Here is the pattern in code. Keys are strings; values are WeakRef objects. On a hit where the wrapped object survives, return it; otherwise re-fetch and store a fresh WeakRef:
function fetchImg() {
// stand-in for real image-downloading logic...
}
function weakRefCache(fetchImg) { // (1)
const imgCache = new Map(); // (2)
return (imgName) => { // (3)
const cachedImg = imgCache.get(imgName); // (4)
if (cachedImg?.deref()) { // (5)
return cachedImg?.deref();
}
const newImg = fetchImg(imgName); // (6)
imgCache.set(imgName, new WeakRef(newImg)); // (7)
return newImg;
};
}
const getCachedImg = weakRefCache(fetchImg);
Walking through the numbered points:
weakRefCacheis a higher-order function. It takesfetchImg— whatever your real download logic is — and returns a caching wrapper around it. The details offetchImgdo not matter here.imgCacheis the store: aMapfrom image names (strings) toWeakRefobjects.- The returned function takes an image name. That name doubles as the cache key.
- It looks up the key in the cache.
- If an entry exists and its wrapped object is still alive,
deref()returns the image and we hand it straight back. - On a miss — no entry, or
deref()returnedundefinedbecause the collector already reclaimed the wrapped image — we download the image again. - We wrap the fresh image in a
WeakRefand store it under the same key.
The result is a Map where keys are image names and values are WeakRef wrappers around the images. Objects nobody else references can be reclaimed, so you avoid holding a pile of heavy data that is no longer in active use, while still getting fast reuse when the object happens to survive.
There is a catch, though. Nothing here removes the keys. Over time the Map accumulates string keys whose WeakRef values now deref to undefined — the images are gone, but the bookkeeping lingers:
Two ways to deal with the dead keys: sweep the cache periodically and drop entries whose deref() is undefined, or hook into cleanup with a finalizer — the subject of the rest of this chapter.
Example 2: tracking a DOM element with WeakRef
Another fit for WeakRef is watching a DOM node without keeping it alive.
Picture some third-party code that talks to an element on your page for as long as that element exists — a logger, say, that streams status messages into a panel. Once the panel is removed from the DOM, the logger should stop.
Try it below. You hand the logger only a WeakRef to the panel. On each tick it calls deref() and appends a line while the panel is still on the page; once you remove the panel, the logger shuts itself down.
Click Start sending messages and a new message #N line appears in the panel roughly once a second. Click the × in the panel’s bar to remove it from the page — on the next tick the logger notices the panel is gone and stops.
Rather than have the logger constantly ask whether the element is still present, you hand it a WeakRef to the element. On each tick it calls deref(). While the element is in the DOM, deref() returns it and logging continues, so the logger observes the element without being the reason it stays in memory.
Read the status line after you close the panel: deref() often still returns the (now detached) node. A weak reference does not disappear the instant you remove an element — the object truly vanishes only once the garbage collector reclaims it, on the engine’s own schedule, which your code cannot dictate. That is why the demo also checks isConnected: it needs an immediate signal, whereas deref() turning undefined can lag well behind.
You cannot force collection from ordinary JavaScript, but browser dev tools can trigger it for testing. In Google Chrome, open dev tools, switch to the “Performance” tab, and click the trash-can “Collect garbage” button:
Most modern browsers expose something similar.
FinalizationRegistry
Time for finalizers. First, the vocabulary:
A cleanup callback (or finalizer) is a function that runs after an object registered with a FinalizationRegistry has been reclaimed by the garbage collector. It is your chance to do follow-up work once the object is truly gone — for instance, delete the stale bookkeeping that Example 1 left behind.
A FinalizationRegistry is a built-in object that tracks registered targets and their cleanup callbacks. It records “when this object dies, call that callback,” and the engine invokes the callback for you after collection happens.
Construct a registry by passing the cleanup callback:
function cleanupCallback(heldValue) {
// cleanup code runs here, after the target is collected
}
const registry = new FinalizationRegistry(cleanupCallback);
The pieces:
cleanupCallback— runs automatically once a registered target is reclaimed.heldValue— the value handed to the callback. It is not the target; it is a companion value you choose at registration time (often a key or id). IfheldValueis itself an object, the registry keeps a strong reference to it, so avoid making it the very object you are tracking.registry— theFinalizationRegistryinstance.
Its methods:
-
register(target, heldValue [, unregisterToken])— start trackingtarget. Whentargetis collected, the callback is (eventually) called withheldValue.The optional
unregisterTokenlets you cancel tracking later. Commonly you pass thetargetitself as the token. -
unregister(unregisterToken)— stop tracking, using the token you registered with. Use this when you no longer care about the target’s demise, so the callback won’t fire for it.
A minimal run-through. Reuse the user object and set up a registry:
let user = { name: "Maya" };
const registry = new FinalizationRegistry((heldValue) => {
console.log(`${heldValue} has been collected by the garbage collector.`);
});
Register the object, choosing its name as the held value:
registry.register(user, user.name);
Crucially, the registry does not hold a strong reference to target. If it did, the target could never be collected and the callback would never fire — the feature would defeat itself.
Drop the last strong reference and, at some later point, the callback may run with the held value:
// once the `user` object is collected, the console eventually shows:
"Maya has been collected by the garbage collector."
Even so, the callback is best-effort — sometimes it never runs at all:
- The program ends first (you close the browser tab, for example) before the collector gets around to that object.
- The
FinalizationRegistryinstance itself becomes unreachable. If whatever created the registry goes out of scope, the callbacks it held may simply never fire.
Sweeping the cache with FinalizationRegistry
Back to the weak cache from Example 1. Its flaw was leftover keys: the wrapped images got collected, but their string keys stayed in the Map forever, a slow leak of empty entries.
A FinalizationRegistry fixes that by deleting a key right after its image is reclaimed:
function fetchImg() {
// stand-in for real image-downloading logic...
}
function weakRefCache(fetchImg) {
const imgCache = new Map();
const registry = new FinalizationRegistry((imgName) => { // (1)
const cachedImg = imgCache.get(imgName);
if (cachedImg && !cachedImg.deref()) imgCache.delete(imgName);
});
return (imgName) => {
const cachedImg = imgCache.get(imgName);
if (cachedImg?.deref()) {
return cachedImg?.deref();
}
const newImg = fetchImg(imgName);
imgCache.set(imgName, new WeakRef(newImg));
registry.register(newImg, imgName); // (2)
return newImg;
};
}
const getCachedImg = weakRefCache(fetchImg);
- The registry’s callback receives the image name as its held value. It looks the name up and — only if the entry exists and now derefs to
undefined— deletes it. That guard matters: between the collection and the callback firing, the main program might have re-fetched and re-stored a live image under the same key. The!cachedImg.deref()check makes sure you never delete a fresh entry. - Right after storing a new image’s
WeakRef, register the image with the registry, using its name as the held value, so its future collection triggers the cleanup.
Now the Map trends toward holding only live entries. Each cached image is tracked, and once the collector reclaims one, the callback prunes its dangling key.
The subtle part is timing. A finalizer runs alongside your main program, not in lockstep with it. There is a gap between “the collector marked this object dead” and “the cleanup callback actually ran,” and during that gap your main code keeps executing. It can re-add the key, or even revive an object into memory.
Two consequences to keep in mind:
- In the cleanup callback, re-check with
deref()before deleting, so a key the main program just refreshed survives. - On lookup, be ready for a value that the collector already reclaimed but whose cleanup callback hasn’t run yet — which the
cachedImg?.deref()check already handles.
These race conditions are inherent to FinalizationRegistry. Design around them deliberately.
The simulation below contrasts the two designs. “Fetch an image” adds a WeakRef entry to the cache; “Run garbage collector” reclaims the weakly-held values. Toggle the checkbox to see what changes: without a registry, dead keys pile up; with one, the finalizer prunes each key as its value is reclaimed.
Putting it together in practice
Consider a realistic setting: a user syncs their photos to a cloud service like iCloud or Google Photos and browses them from other devices. On top of plain viewing, such services pile on features:
- Photo editing and video effects.
- Auto-generated “memories” and albums.
- Video montages from a run of photos.
- …and plenty more.
The example below is a deliberately stripped-down version of such a service. The aim is to show WeakRef and FinalizationRegistry working together in a believable scenario.
Here is the layout:
On the left, a cloud library of photos shown as thumbnails. You pick the ones you want and build a collage with the “Create collage” button on the right, then download the result as an image.
For fast page loads, thumbnails download in compressed quality. But building a collage needs the full-size originals. So the two flows fetch different data for the same photo.
The thumbnails are intentionally small — 240x240 pixels — because preview mode never needs the full-resolution files:
Say you want a collage of 4 photos. You select them and click “Create collage.” For each one, the familiar weakRefCache function checks whether the full-size image is already cached. If not, it downloads from the cloud and caches it:
Watch the console. A photo pulled fresh from the cloud is marked FETCHED_IMAGE. Since this is your first collage, the weak cache started empty and all four were downloaded and cached.
Meanwhile the garbage collector does its own thing. An object held in the cache through a weak reference can be reclaimed, at which point the finalizer runs and deletes the key it was stored under. A CLEANED_IMAGE line marks that:
Now suppose you dislike the collage and swap one photo. Deselect one, pick another, and click “Create collage” again:
This time not everything downloads. One image comes straight from the weak cache, flagged CACHED_IMAGE. The collector hadn’t reclaimed it yet, so the cache hit saved a network request and shaved time off building the collage:
Swap another photo and rebuild once more:
Even better this round. Of the 4 selected, 3 came from the weak cache and only 1 needed a download — roughly a 75% cut in network traffic:
Remember, though: none of this is guaranteed. Whether an image survives in the cache depends entirely on the collector’s behavior, which varies by engine and by moment.
Which raises the obvious question — why not use a normal cache you control directly, instead of leaning on the collector’s whims? For the overwhelming majority of cases, that is the right call. You rarely need WeakRef and FinalizationRegistry.
This example exists to show that the same feature can be built a different, more exotic way using these tools. It is not something to depend on when you need steady, predictable results.
You can open this example in the sandbox.
Summary
WeakRef wraps an object in a weak reference so the object can still be collected once no strong references remain. Call deref() to get the object back, accepting that it may already be undefined. It helps trim memory use for large, regenerable objects.
FinalizationRegistry registers a cleanup callback that the engine may run after a tracked object is collected — useful for pruning the stale bookkeeping a weak cache leaves behind. Its timing is unspecified and its execution is not guaranteed, so keep anything that must happen out of the finalizer and in explicit code.