WeakRef and FinalizationRegistry: JavaScript's Most Misunderstood Memory Primitives
WeakRef and FinalizationRegistry: JavaScript's Most Misunderstood Memory Primitives
WeakRef and FinalizationRegistry are JavaScript's only way to interact with garbage collection—and they're intentionally designed to be unpredictable. Understanding what they guarantee, what they don't, and why they work this way is essential before using them in production. Used correctly, they enable sophisticated memory management patterns. Used incorrectly, they create subtle bugs that manifest only under specific GC timing.
What WeakRef Actually Guarantees
┌─────────────────────────────────────────────────────────────────────────────┐
│ WEAKREF GUARANTEES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ WHAT WEAKREF GUARANTEES: │
│ ──────────────────────── │
│ • deref() returns the target OR undefined │
│ • Multiple calls in same synchronous execution return consistent result │
│ • Target won't be collected during same turn of event loop │
│ │
│ WHAT WEAKREF DOES NOT GUARANTEE: │
│ ─────────────────────────────── │
│ • When the target will be collected (could be never!) │
│ • That the target will ever be collected │
│ • Any specific timing between becoming unreachable and being collected │
│ • Consistent behavior across JavaScript engines │
│ • Consistent behavior across runs of the same program │
│ │
│ THE FUNDAMENTAL RULE: │
│ ──────────────────── │
│ WeakRef.deref() may return undefined at ANY point after the target │
│ becomes unreachable by other means. But it might also keep returning │
│ the target indefinitely if GC never runs. │
│ │
│ This means: │
│ • Your code MUST work correctly if deref() returns undefined │
│ • Your code MUST work correctly if deref() returns the object forever │
│ • You CANNOT rely on finalizers for correctness │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The WeakRef Lifecycle
// Creating and using a WeakRef
let target = { data: 'important' };
const ref = new WeakRef(target);
// Strong reference exists
console.log(ref.deref()); // { data: 'important' }
// Remove strong reference
target = null;
// Target is now ELIGIBLE for collection, but NOT necessarily collected
console.log(ref.deref()); // { data: 'important' } - still there!
// Later, after some GC cycles...
console.log(ref.deref()); // undefined - finally collected
// OR still { data: 'important' } - GC hasn't run yet!
// The critical insight: You cannot predict when this transition happens
Synchronous Consistency Guarantee
// Within a single synchronous execution, deref() is consistent
function processData(weakRef) {
const obj1 = weakRef.deref();
const obj2 = weakRef.deref();
// GUARANTEED: obj1 === obj2 (both object or both undefined)
// GC won't run in the middle of synchronous code
if (obj1) {
doSomething(obj1);
doSomethingElse(obj1); // Safe! obj1 won't become undefined here
}
}
// HOWEVER: Between async operations, no guarantee
async function processDataAsync(weakRef) {
const obj1 = weakRef.deref();
await fetch('/api/data'); // GC could run here!
const obj2 = weakRef.deref();
// NOT GUARANTEED: obj1 === obj2
// obj1 could be valid, obj2 could be undefined
}
// Always re-check after async boundaries:
async function safeProcessing(weakRef) {
const obj = weakRef.deref();
if (!obj) return;
await fetch('/api/data');
// Re-check after await!
const objAgain = weakRef.deref();
if (!objAgain) return;
processObject(objAgain);
}
FinalizationRegistry: Cleanup Callbacks
// FinalizationRegistry allows you to run code when objects are collected
const registry = new FinalizationRegistry((heldValue) => {
// Called AFTER target is collected
console.log(`Object with id ${heldValue} was collected`);
cleanupResources(heldValue);
});
// Register an object for cleanup notification
let obj = { id: 123, resource: createResource() };
registry.register(obj, obj.id); // heldValue = obj.id
obj = null; // Remove strong reference
// Eventually, sometime, the callback MAY be called
// But it's not guaranteed when, or even if!
FinalizationRegistry Limitations
┌─────────────────────────────────────────────────────────────────────────────┐
│ FINALIZATION CALLBACK TIMING │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Timeline (non-deterministic): │
│ │
│ t=0ms Object becomes unreachable (no strong references) │
│ │ │
│ │ ... time passes ... (could be ms, seconds, or never) │
│ │ │
│ t=??? GC runs, collects object │
│ │ │
│ │ ... more time passes ... │
│ │ │
│ t=??? Finalization callback queued as a task │
│ │ │
│ │ ... callback runs when event loop processes it ... │
│ │ │
│ t=??? Your callback finally executes │
│ │
│ CRITICAL POINTS: │
│ ──────────────── │
│ • Callback runs AFTER collection, not during │
│ • Callback is a normal task (not microtask) │
│ • Multiple callbacks may batch together │
│ • Program might exit before callbacks run │
│ • Browser tab might be closed before callbacks run │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Why Non-Determinism Is Intentional
The spec authors deliberately made these APIs unpredictable:
// If finalization was deterministic, it would create problems:
// Problem 1: Performance variability
// Guaranteed prompt finalization would require more frequent GC,
// causing unpredictable pauses in your code
// Problem 2: Security issues
// Predictable finalization timing could leak information
// about memory pressure and object lifetimes
// Problem 3: Implementation constraints
// Different engines have different GC strategies
// A deterministic API would force suboptimal implementations
// The solution: Make it explicitly non-deterministic
// This forces developers to write robust code that doesn't
// depend on finalization timing
Real Use Cases
Use Case 1: Cache with Automatic Eviction
// A cache that allows cached values to be garbage collected
// when memory pressure exists
class WeakValueCache<K, V extends object> {
private cache = new Map<K, WeakRef<V>>();
private registry: FinalizationRegistry<K>;
constructor() {
this.registry = new FinalizationRegistry((key) => {
// Clean up the Map entry when value is collected
const ref = this.cache.get(key);
// Only delete if the ref is dead (might have been replaced)
if (ref && !ref.deref()) {
this.cache.delete(key);
}
});
}
set(key: K, value: V): void {
// Remove old registration if exists
const existing = this.cache.get(key)?.deref();
if (existing) {
this.registry.unregister(existing);
}
const ref = new WeakRef(value);
this.cache.set(key, ref);
this.registry.register(value, key, value);
}
get(key: K): V | undefined {
const ref = this.cache.get(key);
if (!ref) return undefined;
const value = ref.deref();
if (!value) {
// Value was collected, clean up
this.cache.delete(key);
}
return value;
}
// Manual cleanup method - don't rely on finalization alone!
clear(): void {
for (const [key, ref] of this.cache) {
const value = ref.deref();
if (value) {
this.registry.unregister(value);
}
}
this.cache.clear();
}
}
// Usage:
const imageCache = new WeakValueCache<string, ImageBitmap>();
async function getImage(url: string): Promise<ImageBitmap> {
// Try cache first
const cached = imageCache.get(url);
if (cached) return cached;
// Fetch and cache
const response = await fetch(url);
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
imageCache.set(url, bitmap);
return bitmap;
}
// Images can be garbage collected under memory pressure
// Cache automatically cleans up stale entries
Use Case 2: DOM Observer Cleanup
// Automatically clean up observers when their target elements are removed
class SafeMutationObserver {
private observers = new Map<Element, MutationObserver>();
private registry: FinalizationRegistry<MutationObserver>;
constructor() {
this.registry = new FinalizationRegistry((observer) => {
// Element was garbage collected, disconnect its observer
observer.disconnect();
});
}
observe(element: Element, callback: MutationCallback): void {
const observer = new MutationObserver(callback);
observer.observe(element, { childList: true, subtree: true });
this.observers.set(element, observer);
this.registry.register(element, observer);
}
disconnect(element: Element): void {
const observer = this.observers.get(element);
if (observer) {
observer.disconnect();
this.observers.delete(element);
this.registry.unregister(element);
}
}
}
// Even if you forget to call disconnect(), the observer
// will eventually be cleaned up when the element is GC'd
// BUT: Don't rely on this! Always call disconnect() explicitly.
Use Case 3: External Resource Handle
// Tracking handles to native resources that need cleanup
interface NativeHandle {
ptr: number; // Pointer to native resource
close(): void;
}
class ResourceManager {
private handles = new Set<NativeHandle>();
private registry: FinalizationRegistry<number>;
constructor() {
this.registry = new FinalizationRegistry((ptr) => {
console.warn(`Resource ${ptr} was leaked! Cleaning up.`);
nativeClose(ptr); // Native call to free resource
});
}
allocate(): NativeHandle {
const ptr = nativeAllocate();
const handle: NativeHandle = {
ptr,
close: () => {
this.release(handle);
}
};
this.handles.add(handle);
this.registry.register(handle, ptr, handle);
return handle;
}
release(handle: NativeHandle): void {
if (this.handles.has(handle)) {
nativeClose(handle.ptr);
this.handles.delete(handle);
this.registry.unregister(handle);
}
}
// Report any handles that weren't explicitly closed
reportLeaks(): void {
if (this.handles.size > 0) {
console.warn(`${this.handles.size} handles still open`);
}
}
}
// The finalizer acts as a safety net for forgotten close() calls
// But the primary cleanup mechanism is explicit close()
Anti-Patterns: What Not to Do
Anti-Pattern 1: Relying on Finalization for Correctness
// ❌ WRONG: Program correctness depends on finalization
class BrokenCounter {
private static count = 0;
private registry = new FinalizationRegistry(() => {
BrokenCounter.count--; // May never run!
});
create() {
const obj = {};
BrokenCounter.count++;
this.registry.register(obj, undefined);
return obj;
}
getCount() {
return BrokenCounter.count; // Will be wrong!
}
}
// ✅ CORRECT: Finalization is optimization, not correctness
class CorrectCounter {
private active = new Set<object>();
private registry: FinalizationRegistry<void>;
constructor() {
this.registry = new FinalizationRegistry(() => {
// Best effort cleanup - nice to have, not required
});
}
create() {
const obj = {};
this.active.add(obj);
this.registry.register(obj, undefined);
return obj;
}
release(obj: object) {
this.active.delete(obj); // Explicit release
}
getCount() {
return this.active.size; // Always accurate
}
}
Anti-Pattern 2: Expecting Immediate Cleanup
// ❌ WRONG: Expecting cleanup to happen quickly
async function brokenTest() {
let cleaned = false;
const registry = new FinalizationRegistry(() => {
cleaned = true;
});
let obj = {};
registry.register(obj, undefined);
obj = null;
// Force GC (only works with --expose-gc flag)
if (global.gc) global.gc();
// Still won't be true immediately!
console.log(cleaned); // false
await new Promise(r => setTimeout(r, 100));
console.log(cleaned); // Maybe true, maybe false!
}
// ✅ CORRECT: Tests should not depend on GC timing
function correctTest() {
const cache = new WeakValueCache();
// Test set/get
const value = { data: 'test' };
cache.set('key', value);
expect(cache.get('key')).toBe(value);
// Test that get returns undefined for missing keys
expect(cache.get('missing')).toBeUndefined();
// Don't test GC behavior - it's non-deterministic!
}
Anti-Pattern 3: Creating WeakRef in Tight Loops
// ❌ WRONG: Unnecessary WeakRef creation
function brokenProcess(items) {
return items.map(item => {
const ref = new WeakRef(item); // Unnecessary!
return ref.deref()?.value; // Just use item directly
});
}
// ✅ CORRECT: Only use WeakRef when you need weak references
function correctProcess(items) {
return items.map(item => item.value);
}
// WeakRef is for when you want to observe without preventing GC
const cache = new Map();
function getCached(key, compute) {
const ref = cache.get(key);
const cached = ref?.deref();
if (cached) return cached;
const result = compute();
cache.set(key, new WeakRef(result));
return result;
}
Architecture for Non-Determinism
// Design systems that work correctly regardless of GC timing
class RobustResourcePool {
private available: Resource[] = [];
private inUse = new Map<object, Resource>();
private weakRefs = new Map<Resource, WeakRef<object>>();
private registry: FinalizationRegistry<Resource>;
constructor(private maxResources: number) {
this.registry = new FinalizationRegistry((resource) => {
this.handlePossibleReturn(resource);
});
// Pre-allocate resources
for (let i = 0; i < maxResources; i++) {
this.available.push(new Resource());
}
}
acquire(): { token: object; resource: Resource } | null {
// First, try to reclaim any resources from dead tokens
this.reclaimDeadResources();
// Get an available resource
const resource = this.available.pop();
if (!resource) {
// Pool exhausted
return null;
}
// Create a token that tracks this acquisition
const token = {};
this.inUse.set(token, resource);
this.weakRefs.set(resource, new WeakRef(token));
this.registry.register(token, resource, token);
return { token, resource };
}
release(token: object): void {
const resource = this.inUse.get(token);
if (resource) {
this.inUse.delete(token);
this.weakRefs.delete(resource);
this.registry.unregister(token);
this.available.push(resource);
}
}
private handlePossibleReturn(resource: Resource): void {
// Finalizer was called - resource MIGHT be returnable
// But another acquire() might have already reclaimed it
if (!this.available.includes(resource) && !this.isResourceInUse(resource)) {
this.available.push(resource);
}
}
private reclaimDeadResources(): void {
// Actively scan for dead tokens
for (const [resource, ref] of this.weakRefs) {
if (!ref.deref()) {
// Token was collected, reclaim resource
this.weakRefs.delete(resource);
if (!this.available.includes(resource)) {
this.available.push(resource);
}
}
}
}
private isResourceInUse(resource: Resource): boolean {
for (const r of this.inUse.values()) {
if (r === resource) return true;
}
return false;
}
// Explicit cleanup - the primary mechanism
shutdown(): void {
for (const [token] of this.inUse) {
this.release(token);
}
}
}
Key Takeaways
-
WeakRef.deref() can return undefined at any time: After strong references are gone, you cannot predict when. Code must handle both cases.
-
Finalization callbacks are best-effort only: They may run late, batch together, or never run at all. Never use them for correctness.
-
Synchronous execution provides temporary consistency: Within one turn of the event loop, deref() returns consistent results. Re-check after any await.
-
The non-determinism is intentional: It allows engines to optimize GC and prevents timing-based security issues. Embrace it, don't fight it.
-
Use WeakRef for caches and observers: Legitimate use cases involve allowing GC to reclaim memory while maintaining optional references.
-
Always provide explicit cleanup: Finalization is a safety net for forgotten cleanup, not the primary cleanup mechanism.
-
Test without depending on GC: Your tests should verify behavior, not GC timing. Mock or avoid WeakRef in unit tests.
-
Consider alternatives first: Often, explicit lifecycle management (close(), dispose()) is clearer and more predictable than WeakRef.
WeakRef and FinalizationRegistry are powerful tools, but they're intentionally weak guarantees. The spec authors want you to write code that works correctly regardless of GC timing—and that's exactly what production code requires. Use these APIs to optimize memory usage, not to enforce correctness.
What did you think?