Skip to main content

How to Use WeakRef and FinalizationRegistry in JavaScript

JavaScript manages memory automatically through garbage collection. When an object is no longer reachable by any part of your code, the engine reclaims that memory. Normally, if you hold a reference to an object in a variable, an array, or a Map, that object stays alive. But what if you want to reference an object without preventing it from being garbage collected? And what if you want to run cleanup code after the engine collects it?

WeakRef and FinalizationRegistry, both introduced in ES2021, solve these two problems. WeakRef lets you hold a "weak" reference to an object that does not prevent garbage collection. FinalizationRegistry lets you register a callback that fires after an object has been collected. Together, they enable advanced patterns like memory-sensitive caches and automatic resource cleanup.

These are low-level features with important caveats. This guide explains how they work, when to use them, and why you should reach for them only when simpler alternatives fall short.

Understanding Strong vs. Weak References

Before diving into the API, you need to understand the difference between strong and weak references.

Strong References (The Default)

Every normal variable, property, array element, or Map entry that points to an object creates a strong reference. As long as at least one strong reference exists, the garbage collector will not touch that object:

let user = { name: "Alice", data: new Array(1000000) };
// The object has one strong reference: the variable `user`
// It will NOT be garbage collected

let anotherRef = user;
// Now the object has TWO strong references

user = null;
// One strong reference removed, but `anotherRef` still points to the object
// It will NOT be garbage collected

anotherRef = null;
// All strong references removed
// The object is now eligible for garbage collection

Weak References

A weak reference points to an object but does not count as a reason to keep it alive. If the only remaining references to an object are weak ones, the garbage collector is free to collect that object:

let user = { name: "Alice" };

// Create a weak reference to the same object
const weakUser = new WeakRef(user);

// The WeakRef does NOT prevent garbage collection
// Only the variable `user` keeps the object alive

user = null;
// Now the only reference to the object is the weak one
// The garbage collector CAN collect it at any time

You have already encountered weak references in JavaScript through WeakMap and WeakSet. WeakRef gives you the same "non-preventing" behavior but in a more direct, general-purpose form.

WeakRef: Weak References to Objects

The WeakRef class wraps a target object in a weak reference. You create it with new WeakRef(target), and you read the referenced object back with the .deref() method.

Creating a WeakRef

let targetObject = { id: 1, name: "Resource", payload: "large data..." };

const ref = new WeakRef(targetObject);

console.log(ref); // WeakRef {}

The target passed to new WeakRef() must be an object or a non-registered symbol. You cannot create a WeakRef to a primitive:

new WeakRef(42);              // TypeError: Invalid value used as weak reference target
new WeakRef("hello"); // TypeError
new WeakRef(null); // TypeError
new WeakRef(undefined); // TypeError

// Objects and symbols work
new WeakRef({}); // OK
new WeakRef(function(){}); // OK
new WeakRef(Symbol("desc")); // OK (non-registered symbol)
note

Registered symbols (those created with Symbol.for()) cannot be used as WeakRef targets because they are never garbage collected. They live in a global registry for the lifetime of the program.

new WeakRef(Symbol.for("global"));  // TypeError
new WeakRef(Symbol("local")); // OK

Reading the Reference with deref()

The deref() method returns the target object if it is still alive, or undefined if it has been garbage collected:

let obj = { value: 42 };
const ref = new WeakRef(obj);

// Object is still alive
console.log(ref.deref()); // { value: 42 }
console.log(ref.deref().value); // 42

// Later, if all strong references are removed:
obj = null;

// At some point AFTER garbage collection runs:
// ref.deref() will return undefined
// But we cannot predict exactly WHEN

Because garbage collection is non-deterministic, you must always check the return value of deref():

let obj = { name: "Alice" };
const ref = new WeakRef(obj);

// WRONG: might crash if object was collected
// const name = ref.deref().name;

// CORRECT: always check first
const target = ref.deref();
if (target !== undefined) {
console.log(target.name); // Safe to use
} else {
console.log("Object has been garbage collected");
}

A Complete Example

function createWeakReference() {
let heavyObject = {
id: 1,
data: new Array(1000000).fill("x")
};

const weak = new WeakRef(heavyObject);

// At this point, heavyObject is strongly referenced by the local variable
console.log("Before nulling:", weak.deref()?.id); // 1

// Remove the strong reference
heavyObject = null;

// The object MIGHT still be alive right now (GC hasn't run yet)
console.log("After nulling:", weak.deref()?.id); // Probably still 1

return weak;
}

const ref = createWeakReference();

// After the function returns, the local variable `heavyObject` is gone
// The only reference to the object is the WeakRef
// The GC can collect it at any time

// We can check periodically
setTimeout(() => {
const obj = ref.deref();
if (obj) {
console.log("Still alive:", obj.id);
} else {
console.log("Collected by GC");
}
}, 5000);
caution

You cannot force garbage collection in normal JavaScript code. In Node.js, you can call global.gc() if you run with the --expose-gc flag, but this is for testing and debugging only. In production, you have no control over when GC runs.

The Deref-Once-Per-Turn Rule

The ECMAScript specification guarantees that within a single synchronous turn (a single microtask or macrotask execution), if deref() returns the object, subsequent calls to deref() in the same turn will also return the object. The engine will not collect the target mid-execution of your synchronous code:

const ref = new WeakRef(someObject);

// Within the same synchronous block:
const a = ref.deref(); // returns the object (or undefined)
// ... synchronous code ...
const b = ref.deref(); // guaranteed same result as `a`

// But in a LATER turn (setTimeout, promise handler, etc.):
setTimeout(() => {
const c = ref.deref(); // might be undefined even if `a` was defined
}, 1000);

This means you should call deref() once, store the result in a local variable, and use that variable throughout your synchronous logic:

// RECOMMENDED pattern
function doSomethingWithRef(ref) {
const target = ref.deref();
if (!target) return; // Object was collected

// Use `target` freely within this synchronous block
console.log(target.name);
target.process();
target.save();
}

FinalizationRegistry: Cleanup Callbacks After GC

FinalizationRegistry allows you to register a callback that will be called after a target object has been garbage collected. This gives you a hook for cleanup logic: closing connections, releasing external resources, removing cache entries, or logging.

Creating a Registry

You create a registry by passing a cleanup callback function:

const registry = new FinalizationRegistry((heldValue) => {
console.log(`Object associated with "${heldValue}" was garbage collected`);
});

The callback receives a held value that you define at registration time. This value identifies which object was collected (since the object itself is gone by the time the callback fires).

Registering Objects

Use the register() method to track objects:

const registry = new FinalizationRegistry((heldValue) => {
console.log(`Cleaned up: ${heldValue}`);
});

let resource1 = { type: "connection", host: "db.example.com" };
let resource2 = { type: "connection", host: "api.example.com" };

// register(target, heldValue)
registry.register(resource1, "db-connection");
registry.register(resource2, "api-connection");

// When resource1 is collected, the callback fires with "db-connection"
// When resource2 is collected, the callback fires with "api-connection"

The register() method accepts three arguments:

registry.register(target, heldValue, unregisterToken);
ParameterDescription
targetThe object to watch for garbage collection
heldValueAny value passed to the cleanup callback (cannot be the target itself)
unregisterTokenOptional. An object used to unregister later
Important

The heldValue should not be (or contain a strong reference to) the target object. If it did, it would create a strong reference that prevents the target from ever being collected, making the whole mechanism useless:

let obj = { name: "Alice" };

// WRONG: heldValue references the target, keeping it alive forever
registry.register(obj, obj);

// WRONG: heldValue contains a reference to target
registry.register(obj, { ref: obj });

// CORRECT: use an identifier that doesn't reference the target
registry.register(obj, "alice-resource");
registry.register(obj, 42);
registry.register(obj, obj.name); // string copy, not a reference to obj

Unregistering Objects

If you no longer need to track an object (perhaps you manually cleaned up the resource), you can unregister it using the unregister token:

const registry = new FinalizationRegistry((heldValue) => {
console.log(`Cleaned up: ${heldValue}`);
});

let connection = { host: "db.example.com" };

// Use the object itself as the unregister token (common pattern)
registry.register(connection, "db-connection", connection);

// Later, if we manually close the connection:
function closeConnection(conn) {
// ... perform cleanup manually ...
registry.unregister(conn); // Stop tracking: no callback will fire
}

closeConnection(connection);
connection = null;
// Even though the object will be collected, no cleanup callback fires

You can also use a separate object as the unregister token:

const token = {};
registry.register(someObject, "held-value", token);

// Later
registry.unregister(token);

A Complete FinalizationRegistry Example

class ResourceManager {
constructor() {
this.activeResources = new Set();

this.registry = new FinalizationRegistry((resourceId) => {
console.log(`Resource ${resourceId} was garbage collected, performing cleanup`);
this.activeResources.delete(resourceId);
// Could also: close file handles, cancel subscriptions, etc.
});

this.nextId = 0;
}

createResource(data) {
const id = ++this.nextId;
const resource = { id, data, createdAt: Date.now() };

this.activeResources.add(id);
this.registry.register(resource, id);

console.log(`Resource ${id} created. Active: ${this.activeResources.size}`);
return resource;
}

getActiveCount() {
return this.activeResources.size;
}
}

const manager = new ResourceManager();

let res1 = manager.createResource("File handle A"); // Resource 1 created. Active: 1
let res2 = manager.createResource("File handle B"); // Resource 2 created. Active: 2
let res3 = manager.createResource("File handle C"); // Resource 3 created. Active: 3

console.log(manager.getActiveCount()); // 3

// Remove strong references
res1 = null;
res2 = null;

// At some point after GC:
// "Resource 1 was garbage collected, performing cleanup"
// "Resource 2 was garbage collected, performing cleanup"
// manager.getActiveCount() will eventually return 1

Practical Use Cases

Memory-Sensitive Cache

The most common use case for WeakRef is building a cache that holds values as long as they are useful but allows the garbage collector to reclaim them when memory pressure increases:

class WeakCache {
constructor(fetchFunction) {
this.fetchFunction = fetchFunction;
this.cache = new Map();

this.registry = new FinalizationRegistry((key) => {
// Only delete if the WeakRef is actually dead
// (the key might have been re-populated)
const ref = this.cache.get(key);
if (ref && ref.deref() === undefined) {
this.cache.delete(key);
console.log(`Cache entry "${key}" cleaned up after GC`);
}
});
}

get(key) {
const ref = this.cache.get(key);
if (ref) {
const cached = ref.deref();
if (cached !== undefined) {
console.log(`Cache hit: "${key}"`);
return cached;
}
}

// Cache miss or object was collected
console.log(`Cache miss: "${key}", fetching...`);
const value = this.fetchFunction(key);
this.cache.set(key, new WeakRef(value));
this.registry.register(value, key);
return value;
}

get size() {
return this.cache.size;
}
}

// Usage
const imageCache = new WeakCache((url) => {
// Simulate loading a heavy image object
return {
url,
pixels: new ArrayBuffer(1024 * 1024), // 1MB of data
loadedAt: Date.now()
};
});

let img1 = imageCache.get("/photo1.jpg"); // Cache miss, fetching...
let img2 = imageCache.get("/photo1.jpg"); // Cache hit

img1 = null;
img2 = null;
// When GC runs, the image data can be reclaimed
// The FinalizationRegistry will clean up the Map entry

This cache behaves like a standard cache when memory is plentiful (objects stay cached), but under memory pressure, the garbage collector can reclaim cached entries. The FinalizationRegistry ensures the Map does not accumulate dead WeakRef entries.

External Resource Tracking

When JavaScript objects represent external resources (WebSocket connections, file handles, database connections), FinalizationRegistry can serve as a safety net to release those resources if the developer forgets to call a cleanup method:

class DatabaseConnection {
static registry = new FinalizationRegistry((connectionInfo) => {
console.warn(
`WARNING: Database connection to "${connectionInfo.host}" was not properly closed! ` +
`Closing automatically via finalizer.`
);
// Perform actual cleanup
connectionInfo.rawClose();
});

constructor(host, port) {
this.host = host;
this.port = port;
this.isOpen = true;

// Simulate opening a real connection
const rawConnection = this._openRawConnection(host, port);

// Register for cleanup: heldValue contains what we need to close
// but does NOT reference `this`
const cleanupInfo = {
host,
rawClose: () => rawConnection.close()
};

DatabaseConnection.registry.register(this, cleanupInfo);

console.log(`Connection to ${host}:${port} opened`);
}

_openRawConnection(host, port) {
// Simulated raw connection
return {
close() {
console.log(`Raw connection to ${host}:${port} closed`);
}
};
}

close() {
if (this.isOpen) {
this.isOpen = false;
// Unregister since we're cleaning up manually
DatabaseConnection.registry.unregister(this);
console.log(`Connection to ${this.host}:${this.port} closed properly`);
}
}

query(sql) {
if (!this.isOpen) throw new Error("Connection is closed");
return `Result for: ${sql}`;
}
}

// Proper usage
let db1 = new DatabaseConnection("db.example.com", 5432);
db1.query("SELECT * FROM users");
db1.close(); // Closed properly, finalizer will NOT fire
db1 = null;

// Improper usage: developer forgets to call close()
let db2 = new DatabaseConnection("other.example.com", 5432);
db2.query("SELECT * FROM orders");
db2 = null; // Oops, forgot to close!
// Eventually, the finalizer fires and closes the connection as a safety net
warning

FinalizationRegistry should be used as a safety net, not as the primary cleanup mechanism. The cleanup callback may run late, or in some edge cases, may not run at all (e.g., if the page is closed). Always provide an explicit cleanup method (like .close() or .dispose()) and encourage its use.

DOM Element Observer Cleanup

When you associate metadata or observers with DOM elements, WeakRef can prevent memory leaks if those elements are removed from the document:

class ElementTracker {
constructor() {
this.trackedElements = [];

this.registry = new FinalizationRegistry((elementId) => {
this.trackedElements = this.trackedElements.filter(entry => {
const el = entry.ref.deref();
return el !== undefined; // Keep only alive entries
});
console.log(`Element #${elementId} removed, tracker updated. ` +
`Tracking ${this.trackedElements.length} elements.`);
});
}

track(element) {
const ref = new WeakRef(element);
const entry = { ref, id: element.id, trackedAt: Date.now() };
this.trackedElements.push(entry);
this.registry.register(element, element.id);
console.log(`Now tracking #${element.id}`);
}

getAliveElements() {
return this.trackedElements
.map(entry => entry.ref.deref())
.filter(el => el !== undefined);
}
}

Combining WeakRef and FinalizationRegistry: Lazy Loader

A more advanced example combines both features for a lazy-loading system that can release memory and reload when needed:

class LazyResource {
constructor(loader) {
this.loader = loader;
this.ref = null;

this.registry = new FinalizationRegistry(() => {
console.log("Cached resource was collected, will reload on next access");
this.ref = null;
});
}

get() {
if (this.ref) {
const cached = this.ref.deref();
if (cached !== undefined) {
return cached;
}
}

// Load (or reload) the resource
const resource = this.loader();
this.ref = new WeakRef(resource);
this.registry.register(resource, undefined);
return resource;
}
}

// Usage
const heavyData = new LazyResource(() => {
console.log("Loading heavy dataset...");
return {
records: new Array(100000).fill(null).map((_, i) => ({
id: i,
value: Math.random()
}))
};
});

let data = heavyData.get(); // "Loading heavy dataset..."
console.log(data.records.length); // 100000

data = heavyData.get(); // No loading message: cache hit
console.log(data.records.length); // 100000

data = null;
// After GC: "Cached resource was collected, will reload on next access"

// Much later...
data = heavyData.get(); // "Loading heavy dataset..." (reloaded)

Caveats: Non-Deterministic Timing and Other Pitfalls

WeakRef and FinalizationRegistry come with significant caveats that you must understand before using them in production code.

Garbage Collection Is Non-Deterministic

You have no control over when the garbage collector runs. The engine decides based on memory pressure, heuristics, and internal scheduling. This means:

  • deref() might return the object long after all strong references are gone
  • FinalizationRegistry callbacks might fire seconds, minutes, or even hours after the object becomes eligible
  • In some edge cases, callbacks might never fire at all (e.g., the program exits first)
let obj = { data: "important" };
const ref = new WeakRef(obj);

obj = null; // Object is eligible for GC

// This might print the object or undefined (we cannot predict)
console.log(ref.deref()); // Probably still the object (GC hasn't run)

// Even after waiting
setTimeout(() => {
console.log(ref.deref()); // Still might be the object!
}, 10000);
Critical Rule

Never write code that depends on finalization callbacks running at a specific time, or at all. They are a best-effort notification, not a guarantee. Your program must function correctly even if no finalizer ever runs.

Do Not Use for Critical Cleanup

FinalizationRegistry should not be the only mechanism for releasing critical resources:

// BAD: relying solely on finalization for critical cleanup
class FileHandler {
constructor(path) {
this.handle = openFile(path); // hypothetical
registry.register(this, this.handle);
// If the finalizer never runs, the file stays open forever!
}
}

// GOOD: explicit cleanup with finalization as a safety net
class FileHandler {
constructor(path) {
this.handle = openFile(path);
this.closed = false;
registry.register(this, this.handle, this);
}

close() {
if (!this.closed) {
closeFile(this.handle);
this.closed = true;
registry.unregister(this);
}
}

// The finalizer is a safety net, not the primary mechanism
}

Avoid Resurrecting Objects

Inside a FinalizationRegistry callback, do not store the held value in a way that creates a new strong reference to the already-collected object. The object is gone. You only have the held value (an identifier), not the object itself:

const registry = new FinalizationRegistry((heldValue) => {
// heldValue is whatever you passed at registration time
// The original object is ALREADY gone: you cannot access it
console.log(`Object with id ${heldValue} was collected`);
});

Performance Considerations

Creating WeakRefs and using FinalizationRegistry adds overhead:

  • Each WeakRef requires the engine to maintain additional bookkeeping
  • FinalizationRegistry callbacks must be scheduled and executed, which adds to event loop work
  • Excessive use can interfere with GC performance and heuristics

Use these features sparingly, for cases where the memory or resource management benefit outweighs the overhead.

Testing Is Difficult

Because you cannot force GC reliably, testing code that depends on WeakRef and FinalizationRegistry is challenging:

// In Node.js with --expose-gc flag, you can force GC for testing:
function testWeakRef() {
let obj = { value: 42 };
const ref = new WeakRef(obj);

// Object is alive
console.assert(ref.deref() !== undefined);

obj = null;

// Force garbage collection (Node.js only, with --expose-gc)
if (global.gc) {
global.gc();
}

// Now the object should be collected
// But even with forced GC, this is not 100% guaranteed
console.log(ref.deref()); // Likely undefined
}

// Run with: node --expose-gc test.js
testWeakRef();

In browser environments, you cannot force GC at all. Your tests must account for the possibility that objects are not collected during the test run.

The Specification's Intentional Vagueness

The ECMAScript spec intentionally leaves GC behavior implementation-defined. Different engines (V8, SpiderMonkey, JavaScriptCore) may collect objects at different times. Code that "works" in Chrome might behave differently in Safari. This reinforces the rule: never depend on specific timing.

When to Use and When to Avoid

Use WeakRef and FinalizationRegistry When:

  • Building memory-sensitive caches where entries can be reclaimed under memory pressure
  • Implementing safety-net cleanup for external resources (as a backup to explicit cleanup methods)
  • Tracking metadata about objects without preventing their collection
  • Building diagnostic or monitoring tools that observe object lifetimes

Avoid WeakRef and FinalizationRegistry When:

  • A simple WeakMap or WeakSet solves your problem (they are simpler and more predictable)
  • You need guaranteed cleanup timing (use explicit try/finally, using declarations, or manual cleanup)
  • You are working with small or short-lived data (the overhead is not worth it)
  • You are tempted to use them as a primary resource management strategy (they are a safety net only)
tip

The TC39 committee (the group that designs JavaScript) explicitly states in their proposal documentation: "Weak references and finalizers are an advanced feature, and the correct use of them requires careful thought." If a simpler pattern works, use the simpler pattern.

Quick Reference

FeaturePurposeKey MethodCallback Timing
WeakRefHold a reference without preventing GCderef()N/A
FinalizationRegistryRun cleanup code after GCregister() / unregister()Non-deterministic
// WeakRef: basic pattern
const ref = new WeakRef(targetObject);
const obj = ref.deref();
if (obj) { /* use obj */ }

// FinalizationRegistry: basic pattern
const registry = new FinalizationRegistry((heldValue) => {
// cleanup using heldValue
});
registry.register(target, heldValue, unregisterToken);
registry.unregister(unregisterToken);
BrowserWeakRef SupportFinalizationRegistry Support
Chrome84+ (July 2020)84+
Firefox79+ (July 2020)79+
Safari14.1+ (April 2021)14.1+
Edge84+ (July 2020)84+
Node.js14.6+ (July 2020)14.6+

WeakRef and FinalizationRegistry are powerful but niche tools. They fill a gap that no other JavaScript feature covers: observing and reacting to garbage collection. When used correctly (as caching optimizations and safety-net cleanup), they can make your applications more memory-efficient and robust. When misused (as primary cleanup logic or with assumptions about GC timing), they introduce subtle, hard-to-debug issues. Treat them as a last resort after simpler alternatives, and always ensure your code works correctly even if no finalizer ever runs.