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.

STRONG
user━━▶{name}
collector must keep it
WEAK
ref╌╌▶{name}
collector may reclaim it
Strong references pin an object in memory; weak references let it go.

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):

useradmin (WeakRef){name: Maya}strongweak
The object is reachable two ways at once: 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:

admin (WeakRef)?alive objectreclaimed
With only the weak reference left, the object sits in a kind of superposition — still live, or already reclaimed — and you cannot tell which until you look.

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
}
admin.deref()?object (use it)undefinedalivecollected
deref() branches on whether the referent survived.

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.

interactivederef() hands the object back — if it survives

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 Map keyed by name, with the image as the value, pins every image in memory. As long as the entry sits in the Map, the image cannot be collected.
  • A WeakMap does not help either. Its keys are weak, but its values are strong — and here the heavy objects are the values, not the keys. (A WeakMap also 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:

  1. weakRefCache is a higher-order function. It takes fetchImg — whatever your real download logic is — and returns a caching wrapper around it. The details of fetchImg do not matter here.
  2. imgCache is the store: a Map from image names (strings) to WeakRef objects.
  3. The returned function takes an image name. That name doubles as the cache key.
  4. It looks up the key in the cache.
  5. If an entry exists and its wrapped object is still alive, deref() returns the image and we hand it straight back.
  6. On a miss — no entry, or deref() returned undefined because the collector already reclaimed the wrapped image — we download the image again.
  7. We wrap the fresh image in a WeakRef and 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.

imgCache (Map)“logo.png”“hero.png”“map.png”WeakRefWeakRefWeakRefBlobBlobBlob
The weak cache: a Map from image names to WeakRef wrappers, each holding a heavy image through a weak reference.

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:

before collection“hero.png”WeakRef ⇒ Blobafter collection“hero.png”WeakRef ⇒ undefinedkey lingers
When the collector reclaims a wrapped image, its WeakRef starts deref-ing to undefined — but the string key it was stored under stays in the Map.
imgCache (Map)
“a.png”WeakRef ⇒ undefineddead key, still stored
“b.png”WeakRef ⇒ undefineddead key, still stored
“c.png”WeakRef ⇒ Bloblive
After collection, the value is gone but the string key remains — a slow leak of empty entries.

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.

interactiveA logger that watches a panel through a WeakRef

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:

ElementsConsolePerformanceMemoryCollect garbage
Browser dev tools can force a collection for testing: in Chrome, the Performance panel exposes a 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). If heldValue is itself an object, the registry keeps a strong reference to it, so avoid making it the very object you are tracking.
  • registry — the FinalizationRegistry instance.

Its methods:

  • register(target, heldValue [, unregisterToken]) — start tracking target. When target is collected, the callback is (eventually) called with heldValue.

    The optional unregisterToken lets you cancel tracking later. Commonly you pass the target itself 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."
user (target)registryweak trackcollectedcleanupCallback(“Maya”)triggers
Registration links a weakly-held target to a callback; collection triggers it with the held value.

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 FinalizationRegistry instance 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);
  1. 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.
  2. 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.

imagecollectedregistrycallback(name)deletekey
With a FinalizationRegistry wired in, each reclaimed image triggers the callback, which deletes its dangling key so the Map trends toward only live entries.

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.

mainset(k)drop refsset(k) again← live!
GC/reg············collect k····gap····cleanup(k)← must re-check deref()
Two independent timelines. The gap between collection and cleanup is where re-adds sneak in.

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.

interactiveWeak cache: dead keys vs. FinalizationRegistry pruning

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:

cloud librarycollageCreate collage
The demo's layout: a cloud library of photo thumbnails on the left; on the right, a collage built from the selected photos and then downloaded.

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:

240 px240 pxcompressed
Thumbnails download in compressed preview quality at a small 240x240 size — preview mode never needs the full-resolution file.

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:



consoleFETCHED_IMAGEsunset.jpgFETCHED_IMAGEbeach.jpgFETCHED_IMAGEforest.jpgFETCHED_IMAGEcity.jpg
Building your first collage: the weak cache starts empty, so all four full-size photos are downloaded from the cloud — each logged as FETCHED_IMAGE.

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:

consoleCLEANED_IMAGEsunset.jpg key deletedCLEANED_IMAGEforest.jpg key deleted
Meanwhile the collector reclaims a weakly-held image; the finalizer runs and deletes its key, logged as CLEANED_IMAGE.

Now suppose you dislike the collage and swap one photo. Deselect one, pick another, and click “Create collage” again:



collage (4 tiles)removedswapnew pick
Swapping one photo: deselect a tile, pick another, and rebuild the collage.

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:



consoleFETCHED_IMAGEbeach.jpgFETCHED_IMAGEdesert.jpgFETCHED_IMAGEforest.jpgCACHED_IMAGEcity.jpg (cache hit)
On this rebuild, one photo comes straight from the weak cache, flagged CACHED_IMAGE — the collector hadn't reclaimed it yet, so a network request is skipped.

Swap another photo and rebuild once more:



collage (4 tiles)removedswapnew pick
Swap another photo and rebuild the collage 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:



consoleCACHED_IMAGEbeach.jpgCACHED_IMAGEforest.jpgCACHED_IMAGEcity.jpgFETCHED_IMAGEmeadow.jpg3 cache hits1 fetch~75% fewer downloads
This round, 3 of the 4 photos come from the weak cache and only 1 needs 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.