WeakMap vs WeakRef vs FinalizationRegistry: When to Use Each
You need object-keyed metadata, an optional cache, or a cleanup hook, and you’re not sure which of JavaScript’s three weak-reference primitives fits — this page sits under how mark-and-sweep GC works in the JavaScript Memory Fundamentals & Runtime Mechanics section.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
Map keyed by DOM nodes never shrinks |
Strong keys root the node past its removal | Swap Map for WeakMap keyed by the node |
| Cache holds stale huge objects | Map/object cache retains values forever |
Wrap cached value in a WeakRef |
deref() returns undefined next tick |
GC ran between reads, expected behaviour | Null-check every deref() call |
| Cleanup log never fires in tests | Callback timing is deferred, non-deterministic | Treat callback as best-effort only |
WeakMap.size is undefined |
Enumeration is intentionally unsupported | Track count separately with a counter |
Root Cause
The three primitives solve different problems because they attach to different points in how mark-and-sweep GC works: reachability from a root. A normal Map key or object property is a strong reference — as long as the Map itself is reachable, every key it holds is reachable too, so the tracing collector marks it black and it survives forever, even after every other part of the program has forgotten about it. This is precisely the failure mode covered in reference counting vs tracing GC: a live root path, however indirect, keeps the object alive regardless of intent.
WeakMap and WeakSet fix this for the specific case of object-keyed metadata. The key is held weakly — it does not count as a reference during the mark phase — so the moment nothing else references that key, both the key and its associated value become collectible together. Crucially, WeakMap keys must be objects (or, since ES2023, registered symbols), never primitives, because primitives have no identity to track. This is exactly the shape needed for attaching metadata to detached DOM nodes without becoming the reason those nodes stay detached-but-retained in a snapshot.
WeakRef generalises the idea to a single value rather than a map entry. Calling new WeakRef(obj) gives you a handle whose .deref() method returns the object while it’s alive and undefined once it’s gone. Unlike a WeakMap entry, nothing automatically tells you when the transition happens — you poll deref() on demand. This makes WeakRef useful for optional caches (image decode results, parsed ASTs, computed layouts) where recomputing on a miss is acceptable, but dangerous for anything correctness-critical, because the collection point is non-deterministic and cannot be tested reliably in synchronous code.
FinalizationRegistry adds an asynchronous notification: register an object with a callback, and after the engine has collected it, the callback runs — eventually, on the engine’s own schedule, possibly batched, possibly never before shutdown. It exists for diagnostics and releasing external resources tied to a JS wrapper (a WebAssembly handle, a native file descriptor wrapper), not for logic your program correctness depends on.
Step-by-Step Fix
Step 1 — Classify the actual need
Ask: does the data need to disappear because a specific object disappeared (WeakMap), is it an optional single-value cache that can be recomputed (WeakRef), or do you only want a signal for logging/telemetry after collection (FinalizationRegistry)? Most “memory leak” bug reports involving a Map keyed by component instances or DOM nodes resolve to case one.
Verification checkpoint: you can name which one of the three applies before writing any code. If the answer is “I want to run cleanup logic reliably,” none of the three is correct — use an explicit dispose()/unmount lifecycle hook instead.
Step 2 — Replace the strong key with WeakMap
Swap the Map for a WeakMap in the metadata store (see the first code block below). Deploy to a staging environment and drive the workload that previously grew the Map unboundedly.
Verification checkpoint: open DevTools → Memory → Heap Snapshot → Comparison view, filter by the constructor name of your key objects, force GC, and confirm the # New / retained count returns to 0 once the DOM nodes or component instances are removed from the page.
Step 3 — Guard every deref() call
Anywhere a WeakRef is read, add a null check and a recompute fallback (see the second code block). Never assume two calls to .deref() in the same function will return the same result — a GC cycle can run between statements, especially across an await.
Verification checkpoint: in Node.js, run the workload with node --expose-gc script.js and call global.gc() between reads; the fallback path should execute without throwing.
Step 4 — Restrict FinalizationRegistry to diagnostics
Register the callback purely for logging or releasing a non-JS-heap handle (see the third code block). Do not put cache-eviction or correctness logic inside it.
Verification checkpoint: in DevTools → Memory → Heap Snapshot, take a snapshot before and after triggering collection; the callback firing is independent of the snapshot timing — you should observe it can lag by seconds or not fire at all before the tab closes.
Step 5 — Confirm with a heap snapshot comparison
Run the full workload, take Snapshot A, drop all external strong references, click the trash-can icon (Force GC) in DevTools → Memory, take Snapshot B, and switch to Comparison view.
Expected output: the target constructor’s Size Delta is negative and # Delta for the WeakMap-keyed entries drops to 0, confirming the metadata was collected alongside its key rather than lingering as a detached retainer.
Command & Code Reference
Attaching non-enumerable metadata to DOM nodes without rooting them — the common fix for the first symptom-table row:
// Per-node render-cache: keyed by the DOM node itself.
// A regular Map here would keep every node alive forever,
// even after it is removed from the document.
const renderCache = new WeakMap();
function cacheLayout(node, layoutInfo) {
// node is the WeakMap key: held weakly, not rooted.
renderCache.set(node, layoutInfo);
}
function getLayout(node) {
// Returns undefined once node has no other referrer
// and the WeakMap entry has been collected alongside it.
return renderCache.get(node);
}
// When node.remove() runs and no variable still points
// to it, both node and its cached layoutInfo become
// collectible in the same GC cycle — no manual cleanup.
An optional decode cache that must tolerate silent eviction — the fix for the second and third rows:
// Cache of decoded images, wrapped so the engine can
// reclaim entries under memory pressure at any time.
const decodedCache = new Map(); // strong keys (URLs, OK)
// weak VALUES via WeakRef
function getDecoded(url, decodeFn) {
const ref = decodedCache.get(url);
const cached = ref?.deref(); // may be undefined any time
if (cached !== undefined) {
return cached; // still alive — cheap path
}
const fresh = decodeFn(url); // recompute on miss
decodedCache.set(url, new WeakRef(fresh));
return fresh;
// Never assume deref() stays stable across an await —
// re-check it on every read, not just the first.
}
Diagnostics-only cleanup signal for a WebAssembly wrapper — never use this pattern for required teardown:
// FinalizationRegistry callback fires AFTER collection,
// on the engine's own schedule. It is for logging and
// releasing external (non-JS-heap) handles only.
const registry = new FinalizationRegistry((heldValue) => {
// heldValue must not be the collected object itself —
// holding it here would defeat the whole purpose.
console.warn(`[gc] wasm handle ${heldValue} finalized`);
// releaseNativeHandle(heldValue) — OK: external resource.
// cache.delete(heldValue) — risky if logic depends on it.
});
function wrapWasmHandle(jsWrapper, nativeId) {
registry.register(jsWrapper, nativeId);
// Do NOT rely on this callback for required cleanup —
// it may run late, batched, or never before shutdown.
return jsWrapper;
}
Verification & Regression Prevention
Targets:
WeakMap-keyed entries: retained count returns to0in DevTools → Memory → Heap Snapshot → Comparison view within one Mark-Compact cycle of the key becoming unreachable.WeakRef.deref()fallback path: exercised in at least one test that forces GC vianode --expose-gcand asserts the recompute branch runs without error.FinalizationRegistrycallbacks: never gated by application logic; a lint rule or code-review check should flag anyregistry.register()call whose callback mutates shared application state rather than logging or releasing an external handle.
Add a guard test to CI that fails the build if a WeakRef read site is missing a null check:
// weakref-guard.test.js
// Run with: node --expose-gc weakref-guard.test.js
// Fails if a cached WeakRef value is used without a
// deref() null-check immediately before use.
const target = { payload: new Array(10_000).fill(0) };
const ref = new WeakRef(target);
// Drop the only strong reference, then force collection.
// (In real app code this happens naturally over time.)
global.gc();
const value = ref.deref();
if (value === undefined) {
console.log('PASS: deref() correctly returned undefined');
} else {
// Some engines may not collect on this exact tick —
// treat as informational, not a hard CI failure, but
// log so drift in GC behaviour is visible in CI history.
console.log('INFO: object still reachable this cycle');
}
Wire this into your test suite’s pretest or a dedicated memory npm script so a missing null-check regression surfaces before merge rather than in production logs.
Frequently Asked Questions
Does a WeakMap key get garbage collected if the value still holds a reference to it?
Yes, in the typical case where nothing outside the WeakMap references the key. A WeakMap holds its key weakly; the value is held strongly by the map as long as the key survives. If the value happens to reference the key back (value → key), that internal back-reference is exempt from the reachability trace used by mark-and-sweep GC, so it does not keep the key alive on its own. Once every other strong reference to the key is gone, the engine can collect the key and its entry together.
Can I iterate over a WeakMap or check its size?
No. WeakMap and WeakSet intentionally omit .size, .keys(), .values(), .entries(), and forEach(). Exposing enumeration would let code observe collection timing indirectly — an entry could vanish between two loop iterations, which the specification treats as an information leak about GC internals. If you need to enumerate entries, use a strongly-held Map or Set and manage removal explicitly.
Is FinalizationRegistry guaranteed to run before my Node.js process exits?
No. The specification explicitly permits an engine to skip cleanup callbacks entirely during process or tab teardown, and V8 does this routinely so shutdown isn’t delayed waiting on GC bookkeeping. Never rely on FinalizationRegistry for required teardown — closing a file handle, flushing a buffer, releasing a lock. Use an explicit dispose()/close() method or a try/finally block for anything that must run deterministically.
Related
- How Mark-and-Sweep Garbage Collection Works — parent guide covering the tracing algorithm these primitives hook into
- Reference Counting vs Tracing GC Algorithms — why root reachability, not counting, decides what survives
- Detached DOM Nodes and Memory Retention — the DOM-metadata case WeakMap is built for
- Closure Memory Leaks in Modern JavaScript — how a stray strong reference defeats a WeakRef cache
- JavaScript Memory Fundamentals & Runtime Mechanics — main section