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.
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 undefinedThe 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.
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 listenerBuilding 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);
}
}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
-
Always check
deref()forundefined— The target object may be garbage collected at any time. Never assumederef()will return the object. -
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.
-
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.
-
Unregister objects when explicitly disposed — Call
registry.unregister(token)when you manually release a resource to prevent double-cleanup in the FinalizationRegistry callback. -
WeakRef targets must be objects — You cannot create a WeakRef to a primitive value (number, string, boolean, symbol, bigint, undefined, null).
-
Avoid creating WeakRefs in hot loops — Creating and dereferencing WeakRefs has overhead. Cache the dereferenced value in a local variable when possible.
-
Test with forced garbage collection — In Node.js, use
--expose-gcandglobal.gc()to test WeakRef and FinalizationRegistry behavior deterministically. -
Consider WeakMap/WeakSet first — If you need to associate data with an object without preventing its collection,
WeakMaporWeakSetmay be simpler and more appropriate thanWeakRef.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
Using deref() result without null check | TypeError when object is collected | Always check deref() before use |
| Relying on FinalizationRegistry for critical cleanup | Cleanup may never run or run too late | Use explicit dispose() patterns with FinalizationRegistry as a safety net |
| Creating WeakRef to primitives | TypeError | Only create WeakRef to objects |
| Assuming GC timing is predictable | Non-deterministic behavior in tests | Use global.gc() in Node.js tests with --expose-gc flag |
| Memory leaks from Map holding WeakRef keys | The Map entries grow unbounded even though values are collected | Periodically prune dead entries or use FinalizationRegistry |
| Holding strong reference alongside WeakRef | Object is never collected | Ensure 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
| Feature | WeakRef | WeakMap/WeakSet | Manual Cleanup | TTL Cache |
|---|---|---|---|---|
| Automatic eviction | Yes (via GC) | Yes (via GC) | No | Yes (via timer) |
| Cleanup callback | Via FinalizationRegistry | No | Yes | Yes |
| Prevents memory leaks | Partially | Yes | If implemented correctly | Yes |
| Non-deterministic | Yes | Yes | No | No |
| Works with primitives | No | Keys only | Yes | Yes |
| Use case | Caching, observers | Object metadata | Explicit lifecycle | Time-based cache |
| Complexity | Medium | Low | High | Low |
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:
- WeakRef creates a reference to an object that does not prevent garbage collection
- Always check
deref()forundefinedbefore using the referenced object - FinalizationRegistry invokes a callback after an object is garbage collected, but timing is non-deterministic
- Use WeakRef for caches where entries can be recomputed if collected
- Use FinalizationRegistry as a safety net, not as the primary cleanup mechanism
- WeakMap and WeakSet are simpler alternatives when you need to associate data with objects
- Test with
--expose-gcandglobal.gc()for deterministic garbage collection in tests - 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.