MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

JavaScript WeakRef and FinalizationRegistry

Use WeakRef and FinalizationRegistry: weak references, cleanup callbacks, and memory management.

JavaScriptWeakRefMemoryESNext

By MinhVo

Introduction

Memory management in JavaScript is typically invisible to developers. The garbage collector (GC) automatically reclaims memory that is no longer reachable, freeing developers from manual memory management. However, this automatic approach has limitations. There are scenarios where you need to hold a reference to an object without preventing it from being garbage collected, and there are scenarios where you need to perform cleanup when an object is collected. Before ES2021, JavaScript had no mechanism for either of these patterns.

WeakRef and FinalizationRegistry, introduced in ES2021, fill this gap. WeakRef provides a weak reference to an object that does not prevent the garbage collector from reclaiming it. FinalizationRegistry provides a callback mechanism that is invoked after an object has been garbage collected. Together, these APIs enable advanced memory management patterns like caching, resource pooling, and observer cleanup that were previously impossible in JavaScript.

These APIs are powerful but nuanced. They interact with the garbage collector in ways that are non-deterministic and implementation-dependent. Using them incorrectly can lead to subtle bugs, memory leaks, and unpredictable behavior. This guide explores how WeakRef and FinalizationRegistry work, when to use them, and how to avoid their pitfalls.

Memory management concept

Understanding WeakRef and FinalizationRegistry: Core Concepts

The Problem with Strong References

In JavaScript, all object references are strong by default. When you store an object in a variable, array, or Map, that reference prevents the garbage collector from reclaiming the object's memory. This is usually desirable—you want the object to stay alive as long as you are using it. But in certain patterns, this strong reference behavior causes problems.

Consider a caching layer that stores computed results. If the cache holds strong references to its entries, the cached objects can never be garbage collected, even if they are no longer needed elsewhere in the application. Over time, the cache grows unbounded, consuming more and more memory until the application crashes or becomes unresponsive.

Before WeakRef, the only solution was to implement manual cache eviction strategies—time-to-live (TTL), least-recently-used (LRU), or size-based limits. These strategies work but add complexity and require careful tuning. WeakRef provides a simpler alternative: hold a weak reference to the cached object, and let the garbage collector decide when to reclaim it.

WeakRef: A Reference That Does Not Prevent Collection

A WeakRef wraps a target object without creating a strong reference to it. The target object can be garbage collected at any time if there are no other strong references to it. To access the target, you call the deref() method, which returns the target object if it is still alive, or undefined if it has been collected.

let obj = { data: 'important' };
const weakRef = new WeakRef(obj);
 
console.log(weakRef.deref()); // { data: 'important' }
 
obj = null; // Remove the strong reference
 
// The object MAY be garbage collected after this point
// But it is not guaranteed to happen immediately
console.log(weakRef.deref()); // { data: 'important' } OR undefined

The non-deterministic nature of garbage collection means you must always check whether deref() returns undefined before using the result.

FinalizationRegistry: Cleanup After Collection

FinalizationRegistry provides a callback that is invoked after an object registered with it has been garbage collected. This is useful for releasing external resources (file handles, network connections, GPU memory) that the garbage collector cannot reclaim automatically.

const registry = new FinalizationRegistry((heldValue) => {
  console.log(`Object with held value "${heldValue}" was garbage collected`);
});
 
let obj = { data: 'important' };
registry.register(obj, 'my-object');
 
obj = null;
// Eventually, after GC runs:
// "Object with held value 'my-object' was garbage collected"

The callback receives a "held value" that was specified when the object was registered. This value should be a primitive or a simple identifier—not a reference to the collected object, which is no longer available.

Garbage collection visualization

Architecture and Design Patterns

WeakRef Cache Pattern

The most common use case for WeakRef is implementing a cache that automatically evicts entries when the cached objects are no longer used elsewhere in the application:

class WeakCache {
  #cache = new Map();
 
  set(key, value) {
    this.#cache.set(key, new WeakRef(value));
  }
 
  get(key) {
    const ref = this.#cache.get(key);
    if (!ref) return undefined;
 
    const value = ref.deref();
    if (value === undefined) {
      this.#cache.delete(key);
      return undefined;
    }
 
    return value;
  }
 
  has(key) {
    const ref = this.#cache.get(key);
    if (!ref) return false;
    return ref.deref() !== undefined;
  }
}

This cache does not prevent its values from being garbage collected. Once no other code holds a strong reference to a cached value, the garbage collector can reclaim it, and the next get() call will return undefined.

FinalizationRegistry for Resource Cleanup

When your application interacts with external resources that require explicit cleanup, FinalizationRegistry provides a safety net:

const resourceRegistry = new FinalizationRegistry((resourceId) => {
  // Release the external resource
  console.log(`Releasing resource: ${resourceId}`);
  // In a real application, this might close a file handle,
  // release a WebGL buffer, or free native memory
});
 
class ManagedResource {
  #resourceId;
 
  constructor(id) {
    this.#resourceId = id;
    resourceRegistry.register(this, id);
  }
 
  dispose() {
    resourceRegistry.unregister(this);
    console.log(`Manually disposed: ${this.#resourceId}`);
  }
}

Combining WeakRef and FinalizationRegistry

For robust cache implementations, combine WeakRef for automatic eviction with FinalizationRegistry for cleanup:

class RobustCache {
  #cache = new Map();
  #cleanupRegistry = new FinalizationRegistry((key) => {
    this.#cache.delete(key);
    console.log(`Cache entry evicted: ${key}`);
  });
 
  set(key, value) {
    const ref = new WeakRef(value);
    this.#cache.set(key, ref);
    this.#cleanupRegistry.register(value, key);
  }
 
  get(key) {
    const ref = this.#cache.get(key);
    if (!ref) return undefined;
 
    const value = ref.deref();
    if (value === undefined) {
      this.#cache.delete(key);
      return undefined;
    }
 
    return value;
  }
}

Step-by-Step Implementation

Building a WeakRef-Based Image Cache

A practical application of WeakRef is caching decoded images that are expensive to create but can be re-created if needed:

class ImageCache {
  #cache = new Map();
  #registry = new FinalizationRegistry((imageKey) => {
    this.#cache.delete(imageKey);
  });
 
  async getImage(url) {
    // Check cache first
    const cached = this.#cache.get(url);
    if (cached) {
      const img = cached.deref();
      if (img) return img;
    }
 
    // Cache miss — fetch and decode
    const response = await fetch(url);
    const blob = await response.blob();
    const img = await createImageBitmap(blob);
 
    // Store weak reference
    this.#cache.set(url, new WeakRef(img));
    this.#registry.register(img, url);
 
    return img;
  }
 
  get size() {
    return this.#cache.size;
  }
 
  // Manual cleanup of dead references
  prune() {
    for (const [key, ref] of this.#cache) {
      if (ref.deref() === undefined) {
        this.#cache.delete(key);
      }
    }
  }
}

Implementing an Observer Cleanup Pattern

When DOM elements are removed from the document, any event listeners attached to them should be cleaned up. FinalizationRegistry can automate this:

const observerRegistry = new FinalizationRegistry((cleanupFn) => {
  cleanupFn();
});
 
function observeElement(element, eventType, handler) {
  element.addEventListener(eventType, handler);
 
  const controller = new AbortController();
  element.addEventListener(eventType, handler, { signal: controller.signal });
 
  observerRegistry.register(element, () => controller.abort());
}
 
// When the element is garbage collected, the AbortController
// automatically removes the event listener

Building a Weak Object Pool

Object pools that reuse expensive-to-create objects can use WeakRef to allow pooled objects to be collected when the pool itself is no longer needed:

class ObjectPool {
  #available = [];
  #inUse = new WeakSet();
  #factory;
  #reset;
 
  constructor(factory, reset) {
    this.#factory = factory;
    this.#reset = reset;
  }
 
  acquire() {
    let obj;
    if (this.#available.length > 0) {
      obj = this.#available.pop();
    } else {
      obj = this.#factory();
    }
    this.#inUse.add(obj);
    return obj;
  }
 
  release(obj) {
    if (!this.#inUse.has(obj)) return;
    this.#reset(obj);
    this.#available.push(obj);
  }
}

Memory optimization

Real-World Use Cases

DOM Node Caching in Virtual Lists

Virtual list implementations need to cache rendered DOM nodes for fast scrolling. WeakRef allows the cache to release nodes when they scroll far out of view and no strong references remain:

class VirtualListCache {
  #nodeCache = new Map();
 
  getNode(key) {
    const ref = this.#nodeCache.get(key);
    return ref?.deref();
  }
 
  setNode(key, node) {
    this.#nodeCache.set(key, new WeakRef(node));
  }
}

WebSocket Connection Management

When managing WebSocket connections for chat or real-time applications, FinalizationRegistry can ensure connections are closed when the managing object is garbage collected:

const wsRegistry = new FinalizationRegistry((ws) => {
  if (ws.readyState === WebSocket.OPEN) {
    ws.close(1000, 'Manager garbage collected');
  }
});
 
class ChatConnection {
  #ws;
 
  constructor(url) {
    this.#ws = new WebSocket(url);
    wsRegistry.register(this, this.#ws);
  }
 
  close() {
    wsRegistry.unregister(this);
    this.#ws.close();
  }
}

Memoization with Automatic Eviction

Expensive computation results can be cached with WeakRef to allow automatic eviction when the caller no longer references the result:

function createMemoized(fn) {
  const cache = new Map();
 
  return function (...args) {
    const key = JSON.stringify(args);
    const cached = cache.get(key);
    if (cached) {
      const result = cached.deref();
      if (result !== undefined) return result;
    }
 
    const result = fn.apply(this, args);
    cache.set(key, new WeakRef(result));
    return result;
  };
}

Best Practices for Production

  1. Always check deref() for undefined — The target object may be garbage collected at any time. Never assume deref() will return the object.

  2. Do not rely on FinalizationRegistry timing — The callback may never run, or it may run long after the object is collected. Use it for non-critical cleanup only.

  3. Do not use WeakRef as a substitute for proper cache eviction — WeakRef-based caches are useful for supplementary caching, but critical caches should implement explicit LRU or TTL eviction.

  4. Unregister objects when explicitly disposed — Call registry.unregister(token) when you manually release a resource to prevent double-cleanup in the FinalizationRegistry callback.

  5. WeakRef targets must be objects — You cannot create a WeakRef to a primitive value (number, string, boolean, symbol, bigint, undefined, null).

  6. Avoid creating WeakRefs in hot loops — Creating and dereferencing WeakRefs has overhead. Cache the dereferenced value in a local variable when possible.

  7. Test with forced garbage collection — In Node.js, use --expose-gc and global.gc() to test WeakRef and FinalizationRegistry behavior deterministically.

  8. Consider WeakMap/WeakSet first — If you need to associate data with an object without preventing its collection, WeakMap or WeakSet may be simpler and more appropriate than WeakRef.

Common Pitfalls and Solutions

PitfallImpactSolution
Using deref() result without null checkTypeError when object is collectedAlways check deref() before use
Relying on FinalizationRegistry for critical cleanupCleanup may never run or run too lateUse explicit dispose() patterns with FinalizationRegistry as a safety net
Creating WeakRef to primitivesTypeErrorOnly create WeakRef to objects
Assuming GC timing is predictableNon-deterministic behavior in testsUse global.gc() in Node.js tests with --expose-gc flag
Memory leaks from Map holding WeakRef keysThe Map entries grow unbounded even though values are collectedPeriodically prune dead entries or use FinalizationRegistry
Holding strong reference alongside WeakRefObject is never collectedEnsure the strong reference is released when no longer needed

Performance Optimization

WeakRef and FinalizationRegistry have minimal overhead when used correctly, but there are performance considerations to keep in mind:

// BAD: Creating WeakRef in a tight loop
for (let i = 0; i < 1000000; i++) {
  const ref = new WeakRef(objects[i]); // Overhead per iteration
  process(ref.deref()); // Extra indirection
}
 
// GOOD: Cache the dereferenced value
for (let i = 0; i < 1000000; i++) {
  const obj = objects[i]; // Strong reference in scope
  process(obj);
}
 
// GOOD: Use WeakRef only when needed
const ref = new WeakRef(expensiveObject);
function useObject() {
  const obj = ref.deref();
  if (!obj) return fallback();
  return obj.compute();
}

FinalizationRegistry callbacks run asynchronously on the microtask queue. If your callback performs heavy work, consider deferring it to a separate task to avoid blocking the main thread.

Comparison with Alternatives

FeatureWeakRefWeakMap/WeakSetManual CleanupTTL Cache
Automatic evictionYes (via GC)Yes (via GC)NoYes (via timer)
Cleanup callbackVia FinalizationRegistryNoYesYes
Prevents memory leaksPartiallyYesIf implemented correctlyYes
Non-deterministicYesYesNoNo
Works with primitivesNoKeys onlyYesYes
Use caseCaching, observersObject metadataExplicit lifecycleTime-based cache
ComplexityMediumLowHighLow

WeakRef and WeakMap serve different purposes. Use WeakMap when you want to associate data with an object without preventing its collection. Use WeakRef when you need a reference to an object that you can check for liveness.

Advanced Patterns

WeakRef-Based Singleton with Automatic Cleanup

const instanceRegistry = new FinalizationRegistry((name) => {
  console.log(`Singleton "${name}" was garbage collected`);
});
 
class Singleton {
  static #instances = new Map();
 
  static get(name, factory) {
    const ref = Singleton.#instances.get(name);
    if (ref) {
      const instance = ref.deref();
      if (instance) return instance;
    }
 
    const instance = factory();
    Singleton.#instances.set(name, new WeakRef(instance));
    instanceRegistry.register(instance, name);
    return instance;
  }
}

WeakRef Dereference Guard

A utility function that provides a safe dereference with automatic cleanup:

function withWeakRef(ref, callback, fallback) {
  const target = ref.deref();
  if (target === undefined) {
    return fallback();
  }
  return callback(target);
}
 
// Usage
const ref = new WeakRef(someObject);
const result = withWeakRef(
  ref,
  (obj) => obj.compute(),
  () => defaultValue
);

Batch Cleanup with FinalizationRegistry

For applications that create many small objects, batch cleanup is more efficient than individual callbacks:

class BatchCleanup {
  #pending = [];
  #registry;
  #batchSize;
 
  constructor(batchSize = 100) {
    this.#batchSize = batchSize;
    this.#registry = new FinalizationRegistry((heldValue) => {
      this.#pending.push(heldValue);
      if (this.#pending.length >= this.#batchSize) {
        this.flush();
      }
    });
  }
 
  register(target, cleanupData) {
    this.#registry.register(target, cleanupData);
  }
 
  flush() {
    if (this.#pending.length === 0) return;
    // Process all pending cleanups at once
    const batch = this.#pending.splice(0);
    // e.g., send cleanup request to server
    console.log(`Cleaning up ${batch.length} resources`);
  }
}

Testing Strategies

Testing WeakRef and FinalizationRegistry requires forcing garbage collection:

import { describe, it, expect } from 'vitest';
 
// Run with: node --expose-gc node_modules/.bin/vitest
 
describe('WeakRef', () => {
  it('returns undefined after object is collected', () => {
    let obj = { value: 42 };
    const ref = new WeakRef(obj);
 
    expect(ref.deref()).toEqual({ value: 42 });
 
    obj = null;
    global.gc();
 
    expect(ref.deref()).toBeUndefined();
  });
});
 
describe('FinalizationRegistry', () => {
  it('calls callback after object is collected', async () => {
    let callbackCalled = false;
    const registry = new FinalizationRegistry(() => {
      callbackCalled = true;
    });
 
    let obj = { data: 'test' };
    registry.register(obj, 'test-key');
 
    obj = null;
    global.gc();
 
    // FinalizationRegistry callbacks are async
    await new Promise((r) => setTimeout(r, 100));
    expect(callbackCalled).toBe(true);
  });
});

Future Outlook

WeakRef and FinalizationRegistry are established parts of the ECMAScript specification and are supported in all modern browsers and Node.js. They are increasingly used in framework internals—React, Vue, and Angular all use WeakRef or WeakMap for internal optimizations like component caching and observer cleanup.

The JavaScript engine implementations continue to improve the performance and reliability of WeakRef dereferencing and FinalizationRegistry callbacks. V8, SpiderMonkey, and JavaScriptCore have all optimized their garbage collectors to handle WeakRef patterns efficiently.

As more frameworks adopt these APIs, developers will encounter them more frequently in library code. Understanding how they work will become essential for debugging memory issues and contributing to framework internals.

Conclusion

WeakRef and FinalizationRegistry extend JavaScript's automatic memory management with tools for advanced patterns that were previously impossible. WeakRef allows you to hold references without preventing garbage collection, and FinalizationRegistry provides cleanup callbacks for when objects are collected.

Key takeaways:

  1. WeakRef creates a reference to an object that does not prevent garbage collection
  2. Always check deref() for undefined before using the referenced object
  3. FinalizationRegistry invokes a callback after an object is garbage collected, but timing is non-deterministic
  4. Use WeakRef for caches where entries can be recomputed if collected
  5. Use FinalizationRegistry as a safety net, not as the primary cleanup mechanism
  6. WeakMap and WeakSet are simpler alternatives when you need to associate data with objects
  7. Test with --expose-gc and global.gc() for deterministic garbage collection in tests
  8. These APIs are production-ready and supported in all modern JavaScript runtimes

For deeper exploration, see the MDN WeakRef documentation, the MDN FinalizationRegistry documentation, and the TC39 WeakRef proposal.