WeakRef and finalizationregistry in JavaScript
WeakRef holds a reference to an object without preventing garbage collection. FinalizationRegistry runs a callback when that object is actually collected.
Theory
TL;DR
- WeakRef is like a sticky note with someone's number: it doesn't keep them in town, but you can check if they're still around
.deref()returns the object if it's still alive, orundefinedif it's been collected- FinalizationRegistry fires a callback after GC collects the registered object - timing is not guaranteed and may be delayed by seconds or more
- Use these for caches, observer cleanup, or optional resource tracking where you want objects to be collectable
- Never use FinalizationRegistry for time-critical cleanup like closing file handles or releasing locks
Quick example
// Normal reference keeps object alive
let obj = { id: 1 };
const ref = obj;
obj = null;
console.log(ref.id); // 1 - still alive, ref is a strong reference
// WeakRef does not prevent garbage collection
let obj2 = { id: 2 };
const weakRef = new WeakRef(obj2);
obj2 = null;
// obj2 is now eligible for collection
console.log(weakRef.deref()?.id); // 2 if still alive, undefined if collectedThe difference is in how the GC counts references. Strong references are counted. WeakRef is not. Once all strong references are gone, the object is eligible for collection no matter how many WeakRefs point at it.
Key difference
The garbage collector tracks strong references to decide what to keep alive. WeakRef does not add to that count. When you call .deref(), you either get the object or undefined - you cannot know in advance which one. FinalizationRegistry watches for the collection event and schedules a callback, but that callback runs during a future GC cycle, not at the moment the last strong reference disappears.
When to use
- WeakRef: caches where entries should disappear once the original object is no longer used elsewhere; observer patterns where listeners should not keep subjects alive; DOM node tracking in framework internals
- FinalizationRegistry: logging when cache entries are evicted by GC; releasing native resources in Node.js add-on bindings; cleaning up subscriptions in observable libraries when explicit unsubscribe is missing
How the engine handles this
V8, SpiderMonkey, and JavaScriptCore all maintain a separate weak reference table that does not contribute to reachability. During a GC pass, if an object has no strong references, it gets marked for collection regardless of any WeakRefs pointing at it. After the object is freed, the engine queues FinalizationRegistry callbacks, but the exact timing depends on GC cycles, heap pressure, and the runtime environment. I've seen teams waste hours debugging cleanup code that assumed these callbacks behaved like destructors - they don't.
Common mistakes
Mistake 1: Assuming FinalizationRegistry callbacks run immediately
An object can be unreachable for seconds or more before the callback fires. Some GC implementations may defer it across many cycles.
// Wrong - relies on callback for timely resource cleanup
const registry = new FinalizationRegistry(() => {
fileHandle.close(); // May not run for a long time
});
registry.register(fileStream, fileHandle);
// Right - explicit cleanup, guaranteed and immediate
class FileStream {
close() {
this.fileHandle.close();
}
}
stream.close();Mistake 2: Passing the object itself as the held value
// Wrong - creates a strong reference inside the registry, prevents collection
registry.register(obj, obj);
// Right - pass only an identifier or metadata
registry.register(obj, obj.id);Mistake 3: Not handling undefined from .deref()
// Wrong - crashes with TypeError if object was collected
const config = new WeakRef(globalConfig);
const value = config.deref().setting;
// Right
const target = config.deref();
const value = target?.setting; // safely undefined if collectedMistake 4: Using WeakRef with primitives
// Wrong - throws TypeError
new WeakRef(42);
new WeakRef("hello");
// Right - objects only
new WeakRef({ value: 42 });
new WeakRef([1, 2, 3]);Primitives are immutable and interned by the engine. There is no object identity to track, so weak references to them make no sense.
Mistake 5: Confusing WeakRef with WeakMap
WeakMap uses weak keys: you associate data with objects, and the entire entry disappears automatically when the key is collected. WeakRef is an explicit handle you check manually with .deref(). Use WeakMap for "metadata about objects"; use WeakRef for "is this specific object still alive?"
// WeakMap - entry disappears automatically when key is collected
const wm = new WeakMap();
let user = { id: 1 };
wm.set(user, { role: "admin" });
user = null; // entry gone on next GC
// WeakRef - you control the check
const wr = new WeakRef(user);
const alive = wr.deref(); // you decide what to do with undefinedReal-world usage
- React: tracking DOM nodes in refs without blocking their collection; FinalizationRegistry in cleanup for native bindings
- Node.js streams: detecting when stream objects are dereferenced to trigger cleanup callbacks
- Electron: managing native window handles to prevent memory leaks when JS objects outlive closed windows
- Web Workers: auto-removing dead workers from a pool via FinalizationRegistry callbacks
- RxJS and similar observable libraries: cleaning up subscriptions when observers are collected without an explicit
unsubscribe()call
Follow-up questions
Q: Why can't WeakRef work with primitives like numbers or strings?
A: Primitives are immutable and interned by the engine - there is no meaningful object identity to track. Only objects have stable identities that the GC can monitor and collect.
Q: If FinalizationRegistry callbacks aren't guaranteed to run, when should you actually use it?
A: Use it for non-critical work: logging, metrics, cache invalidation, or releasing resources that have fallbacks. Never use it where correctness depends on timely cleanup.
Q: Can you create a memory leak with WeakRef?
A: Yes. If you store the .deref() result in a strong reference and never clear it, you've kept the object alive anyway. Also, if your FinalizationRegistry callback holds a reference to the collected object through a closure, you create a cycle that blocks collection.
Q: What is the difference between WeakRef and WeakMap, and when would you use each?
A: WeakMap pairs data with an object as a key - when the key is collected, the entry disappears automatically. WeakRef is an explicit handle where you call .deref() yourself to check. WeakMap for "metadata about objects"; WeakRef for "is this object still alive?"
Q (Senior): Design a cache that auto-evicts entries when values are collected but also enforces a maximum size. What edge cases matter?
A: Use WeakRef for values and FinalizationRegistry for cleanup callbacks, plus an LRU list of strong references for size capping. Key edge cases: the callback may arrive after the cache is destroyed; holding the cache itself in a closure can block GC of cached objects; the callback may fire after you've already replaced an entry under the same key so check before deleting; the size limit is a soft guarantee, not a hard one.
Examples
Basic: WeakRef in a simple cache
class SimpleWeakCache {
#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) {
this.#cache.delete(key); // Remove stale entry
}
return value;
}
}
const cache = new SimpleWeakCache();
let user = { name: "Alice" };
cache.set("alice", user);
console.log(cache.get("alice")); // { name: "Alice" }
user = null; // Remove strong reference
// After next GC cycle: cache.get("alice") returns undefinedThe get method always checks .deref() before returning. If the object was collected, it removes the stale Map entry so the cache doesn't grow unboundedly.
Intermediate: Auto-cleaning cache with FinalizationRegistry
class AutoCache {
#cache = new Map();
#registry = new FinalizationRegistry((key) => {
// Fires after GC collects the value - not immediate
this.#cache.delete(key);
console.log(`Cache entry "${key}" was collected`);
});
set(key, value) {
this.#cache.set(key, new WeakRef(value));
this.#registry.register(value, key); // key is the held value, not the object
}
get(key) {
return this.#cache.get(key)?.deref();
}
}
const cache = new AutoCache();
let session = { userId: 42, token: "abc123" };
cache.set("session:42", session);
console.log(cache.get("session:42")); // { userId: 42, token: "abc123" }
session = null;
// At some future GC cycle: 'Cache entry "session:42" was collected'The FinalizationRegistry receives key as its held value, not the session object. Passing session to the registry would hold a strong reference to it and prevent collection entirely.
Advanced: Verifying callback timing
// FinalizationRegistry callbacks are NOT synchronous
let collected = false;
const reg = new FinalizationRegistry(() => {
collected = true;
});
let temp = {};
reg.register(temp, null);
temp = null;
console.log(collected); // false - callback has not fired yet
// In a short-lived script, the callback may never fire at all.
// The engine decides when to run GC based on heap pressure, not your code.This is the senior-level trap. Setting a variable to null does not trigger GC. The engine runs collection based on internal heuristics. In a script that exits quickly, registered callbacks may never run. For anything that must happen, use explicit cleanup methods.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.