WeakMap & Reachability
The Cache That Ate Your Memory
You've probably written this exact pattern. It ships in production code every single day:
const cache = new Map();
function getMetadata(element) {
if (cache.has(element)) return cache.get(element);
const meta = computeExpensiveMetadata(element);
cache.set(element, meta);
return meta;
}
This cache grows forever. Even after DOM elements are removed from the document, the Map holds strong references to them. The elements and their entire subtrees can never be garbage collected. Over hours of user interaction, this silently leaks megabytes.
Replace Map with WeakMap and the leak disappears. Sounds like magic, right? It's not — but the answer requires understanding how V8 decides what's alive and what's garbage.
Reachability: The Only Rule That Matters
Think of memory as a city. The GC roots — global object, call stack, active closures — are city hall. Every object reachable by following references from city hall is a resident and stays alive. Objects with no path from city hall are abandoned buildings. The garbage collector demolishes them. A WeakMap entry is like a sticky note on a building: this building has metadata. But the sticky note alone does not prevent demolition. If no road leads from city hall to the building, it gets demolished — and the sticky note goes with it.
V8's mark-and-sweep algorithm starts from GC roots and walks every reference:
GC Roots (always reachable):
├── Global object (window / globalThis)
├── Currently executing call stack
├── Active closures referenced by the stack
└── Registered callbacks (setTimeout, event listeners, etc.)
Mark phase: walk all references from roots, mark every object found
Sweep phase: free all unmarked objects
An object is reachable if there is any chain of strong references from a root to it. If not, it's garbage — regardless of how many other dead objects point to it. That's the whole algorithm. Everything else is built on top of this.
Strong vs Weak References
The distinction is simple but the implications are huge. A strong reference is any normal JavaScript reference — a variable, a property, an array element. Strong references keep objects alive.
A weak reference does not prevent garbage collection. If the only references to an object are weak, the GC can reclaim it. The object just... disappears.
// Strong reference — obj stays alive as long as map exists
const map = new Map();
map.set(key, value); // key is strongly held
// Weak reference — obj can be GC'd even while in the weakmap
const weakmap = new WeakMap();
weakmap.set(key, value); // key is weakly held
WeakMap: Metadata Without Leaks
Now let's get into the specifics. WeakMap has strict constraints, and each one exists for a good reason:
- Keys must be objects (or non-registered symbols) — primitives cannot be weakly held because they are not heap-allocated
- Keys are held weakly — if nothing else references the key, the entry is removed
- Not enumerable — no
.keys(),.values(),.entries(),.size. You cannot observe which entries exist without already holding the key - Values are held strongly — but only as long as the key is alive
const metadata = new WeakMap();
(function () {
const element = document.createElement('div');
metadata.set(element, { clicks: 0, lastSeen: Date.now() });
// element is reachable here — entry stays
})();
// element is now unreachable — entry will be removed by GC
Ephemeron Tables in V8
WeakMap is implemented using ephemeron tables — a special GC data structure where entry liveness depends on key liveness. During marking, V8 cannot process WeakMap entries immediately because the key might not be marked yet. Instead, it defers WeakMap processing to a fixpoint loop:
- Mark all strongly reachable objects
- Scan all WeakMap entries: if a key is marked, mark the value
- If any new values were marked in step 2, go back to step 2 (new values might be WeakMap keys)
- Repeat until no new marking happens (fixpoint)
- Entries with unmarked keys are cleared
This is why WeakMap has slightly more GC overhead than Map — the fixpoint loop adds processing time proportional to WeakMap size.
Real-World Use Cases
Private data per object — the classic pattern before private fields:
const _private = new WeakMap();
class Component {
constructor() {
_private.set(this, { renderCount: 0, mounted: false });
}
render() {
const data = _private.get(this);
data.renderCount++;
}
}
// When a Component instance is GC'd, its private data is too
DOM element metadata — the cache fix from the introduction:
const elementData = new WeakMap();
function trackElement(el) {
elementData.set(el, {
observer: new IntersectionObserver(/* ... */),
measurements: [],
});
}
// When el is removed from DOM and all references dropped, data is GC'd
WeakSet: Membership Without Retention
WeakSet is simpler — it tracks whether an object is in the set, without holding it alive:
const processed = new WeakSet();
function processOnce(obj) {
if (processed.has(obj)) return;
processed.add(obj);
// ... do work
}
// When obj is GC'd, it is automatically removed from processed
Use cases: tracking visited nodes in graph traversal, preventing double-processing, branding objects.
WeakRef: Explicit Weak References
WeakRef is the most direct tool in this family. It gives you a weak reference to a single object. Call .deref() to get the object — or undefined if it has been collected:
let ref = new WeakRef(expensiveObject);
// Later...
const obj = ref.deref();
if (obj) {
// Still alive — use it
obj.doSomething();
} else {
// Collected — recreate or handle absence
}
Do not use WeakRef for caching. The GC is non-deterministic — your cache might be cleared at any time, even mid-function. WeakRef is for cases where you genuinely do not care when the object disappears, like UI observation patterns. For caching, use an LRU cache with explicit size limits.
FinalizationRegistry: Cleanup on Collection
Last one, and this one comes with a big caveat. FinalizationRegistry lets you register a callback that runs after an object is garbage collected:
const registry = new FinalizationRegistry((heldValue) => {
console.log(`Object for ${heldValue} was collected`);
// Clean up external resources
});
function createResource(id) {
const resource = acquireExternalResource(id);
registry.register(resource, id); // id is the "held value"
return resource;
}
FinalizationRegistry callbacks are not guaranteed to run. They are best-effort. The browser may shut down, the page may navigate away, or the GC might not run. Never rely on finalization for correctness — only for opportunistic cleanup of external resources.
Putting It All Together
- 1Map/Set — strong references. Keys and values stay alive as long as the collection exists
- 2WeakMap/WeakSet — weak keys. Entries are removed when the key is garbage collected. Use for associating metadata with objects without preventing their collection
- 3WeakRef — weak reference to a single object. .deref() returns the object or undefined. Use sparingly — not for caching
- 4FinalizationRegistry — best-effort cleanup callback after collection. Never rely on it for correctness
- 5Reachability — the only thing that matters. If no chain of strong references connects an object to a GC root, it is garbage
Q: When would you use WeakMap over Map?
When you need to associate data with objects whose lifetime you do not control. The canonical examples: DOM element metadata, caching computed values for objects that might be removed, and private data associated with class instances. The key insight is that a regular Map creates an ownership relationship — it keeps objects alive. A WeakMap creates an observation relationship — it watches objects without affecting their lifetime.