JavaScript WeakMap, WeakSet, and WeakRef: When Regular Maps Hold On Forever
Every reference in JavaScript is a strong reference by default — it keeps the referenced object alive. Most of the time that is what you want. But sometimes you need to associate data with an object without preventing the object from being garbage-collected. That is what WeakMap, WeakSet, and WeakRef are for.
This is Part 10 of the JavaScript Fundamentals series. You will get the most out of it after reading Part 9 on memory and garbage collection — the whole point of these weak types is influencing GC reachability.
The strong-reference default #
const cache = new Map();
const user = { id: 1, name: 'Alice' };
cache.set(user, { lastSeen: Date.now() });
// Even if no other code references `user`,
// the Map still holds it → it cannot be GCed.
A regular Map holds its keys strongly. As long as the Map exists, every key it contains is alive. This is correct for most caches (you want the entries to persist), but wrong for many metadata use cases where the underlying object might disappear.
WeakMap — weak keys, strong values #
WeakMap keys are held weakly. If no other reference to a key exists, the GC is free to collect it — and the WeakMap entry automatically disappears.
const metadata = new WeakMap();
let user = { id: 1, name: 'Alice' };
metadata.set(user, { firstSeen: Date.now() });
user = null; // last strong reference gone
// next GC sweep: the entry vanishes
Restrictions #
- Keys must be objects (or non-registered Symbols since ES2023). Primitives are not allowed.
- WeakMaps are not iterable. No
forEach, noentries(), nosize. You can onlyget,set,has,deleteby a specific known key.
Why these restrictions? Because GC timing is non-deterministic. If you could iterate a WeakMap, the observable contents would change based on when the GC ran. That would make programs nondeterministic in a way the spec wants to forbid.
Use case: per-DOM-node metadata #
const tooltipState = new WeakMap();
function attachTooltip(el, text) {
tooltipState.set(el, { text, shown: false });
el.addEventListener('mouseenter', show);
el.addEventListener('mouseleave', hide);
}
function show(e) {
const state = tooltipState.get(e.currentTarget);
// ...
}
When the element is removed from the DOM and no longer referenced anywhere, the WeakMap entry vanishes automatically. No cleanup code needed. This is the idiomatic replacement for element.__customData = {...} — and far cleaner.
Use case: "private" fields before # syntax existed #
const privates = new WeakMap();
class User {
constructor(name) {
privates.set(this, { secret: Math.random() });
this.name = name;
}
getSecret() { return privates.get(this).secret; }
}
Nothing outside this file can read privates.get(user) because the WeakMap is module-scoped. Modern classes have #private fields for this, but the WeakMap pattern still appears in libraries supporting older runtimes.
Use case: memoization where the input might go away #
const memo = new WeakMap();
function expensiveAnalysis(obj) {
if (memo.has(obj)) return memo.get(obj);
const result = computeStuff(obj);
memo.set(obj, result);
return result;
}
If obj is garbage-collected later, its memoized result is collected with it. No manual cache eviction. This is exactly the pattern React Fiber, MobX, and Vue use internally for component-to-state lookups.
WeakSet — weak membership #
WeakSet holds objects weakly with no associated values. You can ask "is this object in the set?" but you cannot enumerate it.
const seen = new WeakSet();
function tag(obj) {
if (seen.has(obj)) return; // already processed
seen.add(obj);
process(obj);
}
Same restrictions as WeakMap: object-only members, not iterable.
Use case: cycle detection in graph traversal #
function visit(node, seen = new WeakSet()) {
if (seen.has(node)) return; // already visited
seen.add(node);
node.children.forEach(c => visit(c, seen));
}
The traversal naturally collects every node it touches, and seen GCs after the function returns. No manual cleanup needed.
Use case: "already-processed" flag without polluting the object #
Instead of:
obj.__processed = true; // pollutes the object, leaks if obj is JSON-serialized
Use:
processedItems.add(obj); // WeakSet — clean, no mutation
WeakRef — explicit weak reference to a single object #
The newest addition (ES2021). A WeakRef is a one-element holder for a weak reference. You ask it for the object via .deref(); you get back the object if it is still alive, or undefined if it has been GCed.
let user = { name: 'Alice' };
const ref = new WeakRef(user);
user = null; // drop the strong ref
// ...some time later, after a GC cycle:
ref.deref(); // might be undefined now
Use case: cache that does not prevent GC #
class ImageCache {
constructor() { this.cache = new Map(); }
get(url) {
const ref = this.cache.get(url);
const img = ref?.deref();
if (img) return img;
const fresh = loadImage(url);
this.cache.set(url, new WeakRef(fresh));
return fresh;
}
}
The Map itself uses strings as keys (perfectly fine — primitives are allowed in Map). But the values are WeakRefs. If memory pressure is high, the underlying image can be collected, and the cache entry returns undefined on next lookup — at which point we reload. This is a soft cache: holds when possible, drops under pressure.
FinalizationRegistry — cleanup callbacks when a weakly-held object is collected #
Paired with WeakRef:
const registry = new FinalizationRegistry((heldValue) => {
console.log('GCed:', heldValue);
});
let user = { name: 'Alice' };
registry.register(user, 'Alice was GCed');
user = null;
// some time later: console logs 'GCed: Alice was GCed'
Use cases: closing native handles (file descriptors, sockets, WASM pointers) when a wrapping JS object dies. Almost never needed in pure-JS code.
Anti-patterns and warnings #
The spec explicitly says: "avoid using WeakRef and FinalizationRegistry where possible". Why?
- GC timing is unobservable. Your code may run in browsers that collect aggressively, or in runtimes that almost never collect. Behavior that depends on when GC fires is non-portable.
- Cross-realm issues. A WeakRef in one iframe holding an object from another can behave unexpectedly.
- Subtle bugs around resurrection. If a FinalizationRegistry callback re-stores a reference, the object lives on — but the callback is not called again. This breaks naive cleanup chains.
For caches, prefer bounded LRU or TTL-based approaches over WeakRef. Use WeakRef only when:
- You need to associate data with an object whose lifetime you do not own.
- The data is expensive enough to merit a cache and truly optional (cheap to re-derive).
- You have measured that GC fires often enough for the cache to be useful.
When to use what #
| Need | Use |
|---|---|
| Cache where entries should disappear with their key object | WeakMap |
| Visited-set, processed-flag without polluting the object | WeakSet |
| Soft-cache where memory pressure can drop entries | WeakRef |
| Cleanup native resources on JS object collection | FinalizationRegistry |
| Regular metadata, lookups, iteration | Map / Set |
| Bounded persistent cache | Map + your own LRU/TTL |
A worked example: DOM annotations without leaks #
Classic legacy code:
function addBadge(el, count) {
el.__badgeCount = count;
el.style.position = 'relative';
// ...append badge child...
}
function getBadge(el) {
return el.__badgeCount;
}
Problems:
- Modifies the DOM node directly — pollutes the object.
- Causes hidden-class deoptimization in V8 (adding random props slows down DOM nodes).
- Annotation persists when the node is detached, holding state forever.
Clean version:
const badges = new WeakMap();
function addBadge(el, count) {
badges.set(el, count);
// ...
}
function getBadge(el) {
return badges.get(el);
}
When the element is removed from the DOM and dereferenced elsewhere, its badge data vanishes automatically.
Common gotchas #
Gotcha 1: WeakMap.size does not exist.
No .size, no .clear(), no iteration. If you need any of those, you do not need a WeakMap; you need a Map with manual eviction.
Gotcha 2: WeakMap keys must be objects.
const wm = new WeakMap();
wm.set('foo', 1); // TypeError
wm.set(Symbol('foo'), 1); // OK since ES2023 (only non-registered symbols)
Gotcha 3: A WeakMap entry's value is held strongly! Only the key is weak.
const wm = new WeakMap();
let key = {};
let val = { huge: new Array(1e7) };
wm.set(key, val);
val = null; // wm still has the value via the key
key = null; // NOW the entry is eligible for GC
If you need both weak, use a WeakMap whose value is a WeakRef.
Gotcha 4: WeakRef does not guarantee collection. A WeakRef tells the GC "feel free to collect this if you need to." It does not force collection. In practice, deref() may return your object for a long time — even after the last strong reference is gone — until the GC happens to sweep.
Recap #
- All references are strong by default; weak references let GC reclaim referenced objects.
- WeakMap — weakly-keyed map; entry vanishes when the key has no other strong refs. Use for per-object metadata.
- WeakSet — weakly-held membership set. Use for visited-flags without mutating the object.
- WeakRef — single weak reference to one object. Use sparingly for soft caches.
- FinalizationRegistry — cleanup callbacks; rare, mainly for native-resource bridges.
- WeakMap/WeakSet have no size, no iteration — intentional, because GC timing is non-deterministic.
- All values in a WeakMap are still held strongly — only the key is weak.
Next up: Error Handling Patterns — try/catch beyond the basics, Result types, async error propagation, and the things try/catch will not catch.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


