How to Cache Assets with JavaScript WeakRef

JavaScript’s WeakRef object allows developers to maintain references to large assets—such as parsed data sets, images, or audio buffers—without preventing the garbage collector from freeing that memory when system resources become constrained. In traditional caching mechanisms, standard variable assignments and data structures create strong references that keep objects alive in memory indefinitely. By wrapping cached values in a WeakRef, an application can quickly reuse assets that remain in memory while granting the engine full permission to reclaim that memory whenever necessary.

The Problem with Strong Reference Caching

Standard JavaScript objects, arrays, and collections like Map create strong references. As long as an asset is referenced inside a cache, the garbage collector considers it reachable and will never free its memory. In applications handling heavy resources, this behavior can quickly lead to high memory consumption, degraded performance, or browser crashes unless manual cache eviction strategies (such as LRU algorithms) are carefully managed.

How WeakRef Solves the Issue

A WeakRef holds a reference to a target object without making it strongly reachable. If an object has no remaining strong references pointing to it, the JavaScript garbage collector is allowed to destroy the object and reclaim its memory, even if one or more WeakRef instances still point to it.

To retrieve an object from a weak reference, you call its .deref() method:

Implementing a WeakRef Asset Cache

A typical pattern involves pairing a standard Map with WeakRef wrappers to store assets alongside their retrieval keys:

class AssetCache {
  constructor() {
    this.cache = new Map();
  }

  get(key) {
    const ref = this.cache.get(key);
    if (ref) {
      const cachedAsset = ref.deref();
      if (cachedAsset !== undefined) {
        return cachedAsset; // Cache hit: Asset is still in memory
      }
    }
    return null; // Cache miss or asset was garbage collected
  }

  set(key, asset) {
    this.cache.set(key, new WeakRef(asset));
  }
}

When an asset is requested via get(key): 1. The cache checks if a WeakRef exists for the given key. 2. It attempts to resolve the asset using .deref(). 3. If the asset exists, it is returned without the overhead of re-fetching. 4. If .deref() yields undefined, the application fetches the original asset again and stores a new WeakRef.

Cleaning Up Stale Keys with FinalizationRegistry

While WeakRef allows the asset payload to be reclaimed, the string key and the WeakRef wrapper itself remain inside the Map. To clean up empty cache entries automatically, WeakRef can be combined with FinalizationRegistry.

A FinalizationRegistry allows you to register a callback that runs after a target object has been garbage collected, passing a held value (such as the cache key) to clean up stale entries from the tracking Map:

class SelfCleaningCache {
  constructor() {
    this.cache = new Map();
    this.registry = new FinalizationRegistry((key) => {
      // Clean up the key if the corresponding asset was collected
      const ref = this.cache.get(key);
      if (ref && ref.deref() === undefined) {
        this.cache.delete(key);
      }
    });
  }

  set(key, asset) {
    this.cache.set(key, new WeakRef(asset));
    this.registry.register(asset, key);
  }

  get(key) {
    const ref = this.cache.get(key);
    return ref ? ref.deref() : undefined;
  }
}

Important Considerations