JavaScript WeakRef & FinalizationRegistry Guide
Weak references and cleanup callbacks — patterns, pitfalls, and code template generator for WeakCache, DOM trackers, and resource managers
16 patterns
You’re building an image editor that caches loaded images for undo/redo. A Map of 50 full-resolution images eats 800MB and the tab crashes. You need cached entries to be garbage-collected when memory pressure hits — but you also need a cleanup callback to revoke the object URLs. WeakRef gives you the weak cache; FinalizationRegistry gives you the cleanup hook. The two APIs are always used together, and getting the interaction wrong causes either memory leaks or use-after-free bugs.
Why This Guide (Not MDN)
MDN documents WeakRef and FinalizationRegistry on separate pages with minimal examples. This guide covers both APIs together — the caching pattern, the cleanup pattern, the pitfalls (non-deterministic GC, revival hazards, deref() timing), and the real-world use cases where weak references actually make sense. Everything runs in your browser; no data is sent anywhere.
JavaScript WeakRef & FinalizationRegistry
JavaScript’s WeakRef and FinalizationRegistry — introduced in ES2021 (ES12) — give developers fine-grained control over weak references and garbage collection (GC) callbacks. Together they enable advanced patterns like self-evicting caches, DOM element trackers, and resource leak detection that are otherwise impossible or memory-leaky in plain JavaScript.
What Is a Weak Reference?
A strong reference is a normal JavaScript variable assignment. While a strong reference exists, the garbage collector cannot reclaim the target object.
A weak reference (via WeakRef) holds a pointer to an object without counting as a strong reference. If no other strong references exist, the GC is free to reclaim the object. The WeakRef itself remains valid — but deref() will return undefined after collection.
const obj = { data: new ArrayBuffer(1024 * 1024) }; // ~1 MB
// Strong reference — prevents GC:
const strong = obj;
// Weak reference — does NOT prevent GC:
const weak = new WeakRef(obj);
// If we release the strong reference:
// strong = null; obj = null;
// The GC may now collect the ArrayBuffer.
// weak.deref() → undefined (after collection)
WeakRef API
Constructor
new WeakRef(target)
target must be an object (including functions, arrays, DOM elements) or a registered Symbol (Symbol.for(...)). Passing a primitive (number, string, boolean, null, undefined) or a local Symbol throws TypeError.
deref()
const obj = ref.deref(); // object or undefined
Returns the referenced target if still alive, or undefined if it has been garbage collected. Always store the result in a local variable and check before use.
Same-Turn Guarantee
Within a single synchronous job (one turn of the event loop), if deref() returns non-undefined once, it will continue to do so for the duration of that synchronous block. Across await boundaries or setTimeout callbacks, always re-check.
FinalizationRegistry API
Constructor
const registry = new FinalizationRegistry(cleanupCallback);
cleanupCallback is a function that receives the held value (not the target — it’s already gone). Called sometime after the registered target is garbage collected.
register(target, heldValue, unregisterToken?)
registry.register(obj, "metadata-key");
// or with an unregister token:
const token = {};
registry.register(obj, "metadata-key", token);
- target: The object to watch (must be an object or registered Symbol)
- heldValue: Passed to the callback; must NOT contain a strong reference to target
- unregisterToken (optional): An object used to cancel registration via
unregister()
unregister(token)
registry.unregister(token); // returns true if entry was removed
Cancels the pending cleanup callback. Use this when you clean up explicitly and don’t want the callback to fire afterward.
Common Use Cases
1. Self-Evicting Cache
The most practical use case: a cache that releases entries automatically when values are no longer needed anywhere else in the application.
class WeakCache {
#map = new Map();
#registry = new FinalizationRegistry((key) => this.#map.delete(key));
set(key, value) {
const ref = new WeakRef(value);
this.#map.set(key, ref);
this.#registry.register(value, key, ref);
}
get(key) {
return this.#map.get(key)?.deref();
}
}
Unlike a WeakMap, this cache can use any key type (not just objects) and provides a get() method that returns undefined transparently when the value has been collected.
2. DOM Element Tracking
Track DOM elements and their associated state without preventing elements from being garbage collected after DOM removal.
const elements = new Map(); // id → WeakRef<Element>
const registry = new FinalizationRegistry((id) => elements.delete(id));
function track(id, element) {
const ref = new WeakRef(element);
elements.set(id, ref);
registry.register(element, id, ref);
}
function getElement(id) {
return elements.get(id)?.deref(); // undefined if GC'd
}
3. Resource Cleanup Safety Net
Use FinalizationRegistry to detect leaked resources — file handles, WebSocket connections, database cursors — that were not explicitly closed.
class Connection {
static #registry = new FinalizationRegistry((id) => {
console.warn(`Connection ${id} leaked! Forcing close.`);
forceClose(id);
});
constructor(id) {
this.id = id;
Connection.#registry.register(this, id);
}
close() {
Connection.#registry.unregister(this);
// ... actual close logic
}
}
4. Event Listener Auto-Cleanup
Hold handler objects weakly in event listeners to avoid memory leaks when handlers are destroyed.
function addWeakListener(target, type, handlerObj, method) {
const ref = new WeakRef(handlerObj);
const wrapper = (event) => {
const obj = ref.deref();
if (obj) obj[method](event);
else target.removeEventListener(type, wrapper);
};
target.addEventListener(type, wrapper);
return () => target.removeEventListener(type, wrapper);
}
Garbage Collection Behavior
Non-Determinism
The JavaScript specification intentionally makes GC timing non-deterministic:
- Engines choose when to run GC (under memory pressure, between tasks, etc.)
- Cleanup callbacks may be batched, delayed, or skipped entirely
- Short-lived processes (test runners, CLI scripts) may exit before GC runs
- Different engines (V8, SpiderMonkey, JavaScriptCore) have different GC strategies
This is by design — it prevents authors from relying on GC for program correctness.
GC Eligibility vs. Collection
An object becomes eligible for GC when no strong references remain. But eligibility does not mean immediate collection. The object may live for an unknown additional duration before the callback fires.
Strong reference dropped
│
▼
Object becomes GC-eligible
│
(some time passes — indeterminate)
│
▼
GC collects object
│
▼
FinalizationRegistry callback fires
│
(some time may pass)
│
▼
Your cleanup code runs
Common Pitfalls
| Pitfall | Problem | Fix |
|---|---|---|
| Critical cleanup via FinalizationRegistry | Callback may never run | Use try/finally for deterministic cleanup |
Missing deref() null check | TypeError on undefined | Always const o = ref.deref(); if (!o) return; |
| Target in held value | Strong reference prevents GC | Only hold primitives/IDs as held value |
| WeakRef with primitives | TypeError at construction | Only objects and registered Symbols allowed |
| Relying on callback ordering | Spec gives no ordering guarantee | Treat each cleanup as independent |
WeakRef vs WeakMap vs WeakSet
| Feature | WeakRef | WeakMap | WeakSet |
|---|---|---|---|
| Holds value weakly | ✓ | ✓ (values) | ✓ |
| Key must be object | n/a | ✓ | ✓ |
| GC notification | via FinalizationRegistry | ✗ | ✗ |
| Iterable | ✗ | ✗ | ✗ |
| Can use primitive keys | n/a | ✗ | n/a |
| Access liveness check | via deref() | implicit | .has() |
Rule of thumb: prefer WeakMap for simple object→value associations (simpler, no registry needed). Use WeakRef when you need to hold and conditionally access a reference across time. Add FinalizationRegistry when you need a GC notification for cleanup.
Browser & Engine Support
| Environment | WeakRef | FinalizationRegistry |
|---|---|---|
| Chrome / Edge | 84+ | 84+ |
| Firefox | 79+ | 79+ |
| Safari | 14.1+ | 14.1+ |
| Node.js | 14.6+ | 14.6+ |
| Deno | 1.0+ | 1.0+ |
| TypeScript | 4.1+ (with target: ES2021) | 4.1+ |
Both APIs require target: "ES2021" or higher in tsconfig.json, or explicit lib entries.
Frequently Asked Questions
Does deref() return the same object every time?
Yes, as long as the target is alive. Within a single synchronous job, if deref() returns non-undefined, subsequent calls return the same object. Across async boundaries, the result may differ (the object may have been collected during the async gap).
Can FinalizationRegistry replace try/finally?
No. FinalizationRegistry callbacks are non-deterministic and may not run at all. Always use try/finally, using declarations (TC39 explicit resource management), or explicit close()/dispose() for deterministic cleanup. Use FinalizationRegistry only as a supplementary safety net.
Why can’t I iterate over WeakRef targets?
By design — if WeakRef provided iteration, engines would need to pin all referenced objects in memory during iteration, defeating the purpose. For enumerable weak collections, combine a regular Map (for keys) with WeakRef (for values) and FinalizationRegistry (for cleanup), as in the WeakCache pattern.
Is WeakRef safe to use in production?
Yes, with proper null-checking. The API is stable (ES2021), well-supported across modern engines, and correct TypeScript typings are available. The main gotcha is always checking deref() return values — failing to do so causes TypeErrors on collected objects.
What happens if I call register() multiple times for the same target?
Each call creates an independent entry. The cleanup callback fires separately for each registration. If you register the same target twice, the callback fires twice. Use unregister() with distinct tokens to manage multiple registrations.
All processing is done entirely in your browser — no data is sent to any server.