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.

Three Weak-Reference Tools, Three Jobs Three columns. Column one, WeakMap: a key object and value object, key held weakly so both collect together once no external reference to the key remains. Column two, WeakRef: a handle object whose deref method returns the target while alive and undefined after collection, polled on demand. Column three, FinalizationRegistry: an object registered with a callback that fires asynchronously and non-deterministically sometime after collection, used for diagnostics only. WeakMap key (object) value weak no external ref to key key + value collected together, same GC cycle Use: DOM-node metadata, per-object private state WeakRef handle target call .deref() on demand alive: returns target collected: returns undefined timing not observable in advance Use: optional cache where a recompute-on-miss is cheap FinalizationRegistry registered obj collected engine schedules cleanup non-deterministic delay callback(heldValue) runs Use: diagnostics, releasing an external resource wrapper All three exempt their weak link from GC-root tracing — none of them prevents or forces collection, and none is deterministic; only WeakMap ties cleanup to another live key.

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 to 0 in 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 via node --expose-gc and asserts the recompute branch runs without error.
  • FinalizationRegistry callbacks: never gated by application logic; a lint rule or code-review check should flag any registry.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.