WeakMap, WeakSet and WeakRef
Mental model: A normal collection is a hand gripping its keys, so nothing in it can ever be collected. A weak collection watches its keys instead, and lets go the moment the rest of the program does.
Level: advanced · about 16 minutes
Garbage collection works on reachability: an object survives while some chain of references leads to it from a root. A Map is such a chain. Put a DOM node in a Map as a key and that node can never be collected, even after it is removed from the page, because your map is still pointing at it. Weak collections exist to break that chain.
Map WeakMap
root root
| |
v v
map ==(strong)==> node map --(weak)--> node
^
node is reachable |
forever +-- if nothing else points here,
the node and its entry both go
const meta = new WeakMap();
const user = { name: 'Ada' };
meta.set(user, { lastSeen: 'today', visits: 3 });
console.log(meta.get(user)); // -> { lastSeen: 'today', visits: 3 }
console.log(meta.has(user)); // -> true
console.log(meta.get({ name: 'Ada' })); // -> undefined, keys are compared by identity
console.log(meta.delete(user)); // -> true
console.log(meta.get(user)); // -> undefined
console.log('size' in meta, typeof meta.forEach); // -> false undefinedThe API is deliberately tiny: four methods, no iteration, no size.
const wm = new WeakMap();
const key = { id: 1 };
wm.set(key, 'data');
console.log(wm.has(key), wm.get({ id: 1 }));Weak collections key on object identity, exactly like Map does. { id: 1 } is a brand new object, so it is not the same key, and the lookup misses. There is no structural comparison anywhere in JavaScript collections, which is why deep-equal caching needs a serialised string key instead.
Keys must be objects (with one 2023 exception)
const wm = new WeakMap();
try { wm.set('a', 1); } catch (e) { console.log('string:', e.constructor.name); } // -> string: TypeError
try { wm.set(42, 1); } catch (e) { console.log('number:', e.constructor.name); } // -> number: TypeError
wm.set({}, 1); // objects, fine
wm.set(function () {}, 1); // functions are objects, fine
// ES2023 added unregistered symbols as valid weak keys.
try {
wm.set(Symbol('local'), 'ok');
console.log('unregistered symbol: allowed');
} catch (e) {
console.log('unregistered symbol: not supported here');
}
// A registered symbol is never collectable, so it stays rejected.
try { wm.set(Symbol.for('global'), 1); } catch (e) { console.log('registered symbol: rejected'); }Primitives are rejected because they have no identity to be weak about.
Use one: private instance data
Convention, not privacy
class Account {
constructor(n) {
this._balance = n;
}
get balance() {
return this._balance;
}
}
const a = new Account(100);
a._balance = -999; // nothing stopped you
Object.keys(a); // [ '_balance' ]
JSON.stringify(a); // {"_balance":-999}
Actually unreachable
const balances = new WeakMap();
class Account {
constructor(n) {
balances.set(this, n);
}
get balance() {
return balances.get(this);
}
}
const a = new Account(100);
Object.keys(a); // []
JSON.stringify(a); // {}
// no handle to balances from outsideClass #private fields do the same job with better syntax and better engine support, and they are the right default in 2026. The WeakMap version still matters for two cases: adding private state to objects you did not construct, and code that must run without class field syntax.
const balances = new WeakMap();
class Account {
constructor(initial = 0) {
balances.set(this, initial);
}
deposit(amount) {
balances.set(this, balances.get(this) + amount);
return this;
}
get balance() {
if (!balances.has(this)) throw new TypeError('not an Account');
return balances.get(this);
}
}
const acc = new Account(100).deposit(50);
console.log(acc.balance); // -> 150
console.log(Object.keys(acc), JSON.stringify(acc)); // -> [] {}
const impostor = { deposit: Account.prototype.deposit };
try {
Account.prototype.balance;
} catch (e) {
console.log('brand check:', e.message); // -> brand check: not an Account
}The WeakMap version, with the brand check that makes it robust.
Use two: metadata on objects you do not own
This is the case WeakMap was designed for. You need to remember something about a DOM node, a request, or a third-party object, and you must not be the reason it stays alive.
const listeners = new WeakMap();
function onDrag(node, handler) {
listeners.set(node, handler);
node.addEventListener('pointermove', handler);
}
// Later, the node is removed from the document and dropped from your app state.
// Nothing else references it, so the node, the handler and the WeakMap entry
// all become collectable together. With a Map, all three would leak.Node metadata that disappears with the node.
const sizeCache = new WeakMap();
let computations = 0;
function deepSize(obj) {
if (sizeCache.has(obj)) return sizeCache.get(obj);
computations += 1;
const size = JSON.stringify(obj).length;
sizeCache.set(obj, size);
return size;
}
const config = { a: 1, b: [1, 2, 3] };
console.log(deepSize(config), deepSize(config), computations); // -> 22 22 1
config.c = 'new';
console.log(deepSize(config), computations); // -> 22 1 stale, the cache never saw the changeA cache that cannot outlive its keys. Note the mutation caveat.
WeakSet: marking without owning
const seen = new WeakSet();
function visit(node) {
if (seen.has(node)) return 'already visited';
seen.add(node);
return 'first visit';
}
const a = { id: 'a' };
console.log(visit(a), visit(a)); // -> first visit already visited
// Cycle-safe traversal without polluting the objects you walk.
function hasCycle(start) {
const stack = [start];
const active = new WeakSet();
while (stack.length) {
const node = stack.pop();
if (typeof node !== 'object' || node === null) continue;
if (active.has(node)) return true;
active.add(node);
stack.push(...Object.values(node));
}
return false;
}
const flat = { a: 1, b: { c: 2 } };
const looped = { name: 'loop' };
looped.self = looped;
console.log(hasCycle(flat), hasCycle(looped)); // -> false trueThree methods: add, has, delete. Use it when the answer is a yes or no.
WeakRef: a reference you must ask about
WeakMap makes a key weak. WeakRef makes a single value weak, and hands you a deref() that returns the object or undefined if it has been collected. Every read is a question, and the answer can change between two reads.
let big = { rows: new Array(1000).fill(0) };
const ref = new WeakRef(big);
function report() {
const target = ref.deref(); // one deref, one local
if (!target) return 'the object is gone';
return `still here, ${target.rows.length} rows`;
}
console.log(report()); // -> still here, 1000 rows
big = null; // the only strong reference is dropped
// The object is now *eligible* for collection, but nothing forces it.
console.log(typeof ref.deref()); // -> 'object' or 'undefined', engine's choiceCorrect usage: deref once, into a local, and handle undefined.
FinalizationRegistry: not a destructor
const registry = new FinalizationRegistry((heldValue) => {
console.log('collected:', heldValue); // heldValue must NOT be the object itself
});
let socket = { id: 'ws-1' };
registry.register(socket, 'ws-1', socket); // target, held value, unregister token
socket = null;
// Later, maybe, the callback runs with 'ws-1'.
// registry.unregister(token) cancels it if you clean up first.The API is small. The caveats are not.
| Assumption | Reality |
|---|---|
| the callback always runs | no guarantee at all, and never on page unload |
| it runs soon after the last reference drops | it runs whenever the collector feels like it, possibly minutes later |
| callbacks run in registration order | order is unspecified |
| I can pass the object as the held value | that keeps it alive forever and the callback can never fire |
| it is like a C++ destructor | it is a best-effort notification for caches and diagnostics |
| Need | Reach for |
|---|---|
| private state on your own class | #private fields |
| private state on objects you did not create | WeakMap |
| metadata for DOM nodes or requests | WeakMap |
| has this object been processed? | WeakSet |
| a cache that must not extend lifetimes | WeakMap keyed on an immutable object |
| an optional pointer to a possibly-dead object | WeakRef plus a null check |
| deterministic release of a file, socket or lock | Symbol.dispose and using, never a finalizer |
| iteration, size, or serialisation | Map or Set, and clear entries yourself |
Try it yourself
Watch a registry leak
// A "session store" that leaks, modelled with a Map.
const strong = new Map();
function remember(obj, note) {
strong.set(obj, note);
}
for (let i = 0; i < 5; i++) {
remember({ id: i, payload: new Array(1000).fill(i) }, 'note ' + i);
}
// Nothing else references those objects, yet:
console.log('entries still held:', strong.size); // -> 5
console.log('first key still alive:', [...strong.keys()][0].id);
// The same code with weak keys cannot report a size at all,
// because the answer would depend on the garbage collector.
const weak = new WeakMap();
const keep = { id: 'kept' };
weak.set(keep, 'reachable');
weak.set({ id: 'dropped' }, 'unreachable the moment this line ends');
console.log(weak.get(keep)); // -> reachable
Swap the Map for a WeakMap and try to print registry.size. Explain why the line has to be deleted, not fixed.
Deep freeze without touching the objects
function deepFreeze(obj, seen = new WeakSet()) {
if (obj === null || typeof obj !== 'object') return obj;
if (seen.has(obj)) return obj; // cycle guard, no marker property added
seen.add(obj);
for (const value of Object.values(obj)) deepFreeze(value, seen);
return Object.freeze(obj);
}
const config = { db: { host: 'local', opts: { retry: 2 } } };
config.self = config; // a cycle, which would hang a naive walk
deepFreeze(config);
console.log(Object.isFrozen(config), Object.isFrozen(config.db.opts)); // -> true true
console.log(Object.keys(config.db)); // -> [ 'host', 'opts' ]
Make it handle arrays as well as plain objects, then add a frozen counter so you can prove each object is visited exactly once.
Exercises
Genuinely private balance
Write a class Account whose balance lives in a WeakMap, not on the instance. Support new Account(initial = 0), a balance getter, deposit(amount) and withdraw(amount) (both returning this so they chain). A negative or non-finite amount throws a RangeError, and withdrawing more than the balance throws a RangeError. Calling balance on something that is not an Account throws a TypeError. Object.keys(account) must be empty.
A cache that cannot leak
Write memoizeByObject(fn). The returned function takes exactly one object argument and caches the result per object identity in a WeakMap, so fn runs at most once per object. A primitive argument (including null) must throw a TypeError. Also expose .clear(obj) on the returned function, which forgets one entry and returns true if there was one.
Check yourself
- Why does
WeakMaphave nosizeproperty? - Because the answer would depend on when the garbage collector last ran, making it non-deterministic — Exposing
sizewould leak collector timing into observable program behaviour, so two runs of the same code could disagree. The spec avoids the question entirely, which is also why there is nokeys(),values()orforEach. Any need to enumerate means you want aMapand the manual cleanup that comes with it. - What does this log?
'a'— The secondsetuses a different object that happens to have the same shape, so it creates a separate entry (which immediately becomes unreachable). Looking up the originalkeystill finds'a'. Weak collections, likeMap, compare keys with the same rules as===.- Which of these is a safe use of
FinalizationRegistry? - Logging a warning that a cache entry was dropped, purely for diagnostics — Finalizer callbacks are best effort: they may run late, in any order, or never (they definitely do not run on page unload). That rules out anything correctness-critical, which is the first, second and fourth options. Diagnostics and cache bookkeeping are fine, because the program is still correct if the callback never fires. Deterministic release belongs to
Symbol.disposeandusing. - You need to remember a computed value per DOM node, for nodes that come and go as the user navigates. What do you use?
- a
WeakMapkeyed on the node — AWeakMapis the exact shape of this problem: the entry lives while the node does and disappears with it, with no bookkeeping. TheMapversion works but makes you responsible for a cleanup path you will eventually forget. Adata-attribute stringifies everything and is visible in the DOM, and aWeakSetcannot store values at all.
Common mistakes
- Using a
Mapwhere aWeakMapbelongs, which turns a cache into a leak. - Trying to iterate or count a weak collection. There is no API and that is deliberate.
- Using a primitive as a weak key. Only objects and unregistered symbols are allowed.
- Caching by object identity while callers mutate the object, which serves stale results forever.
- Calling
deref()twice and assuming both answers agree. - Passing the target object itself as the held value in
FinalizationRegistry, which prevents the callback forever. - Treating a finalizer as a destructor. If correctness depends on it, the design is broken.
- Reaching for
WeakMapprivacy when#privatefields would be clearer.
Takeaways
- Weak collections hold keys without keeping them alive, so entries vanish with their keys.
- The API is small on purpose: no size, no iteration, no serialisation, because those would expose collector timing.
- Keys must be objects, functions, or (since ES2023) unregistered symbols.
WeakMapgives you private state and node metadata.WeakSetgives you a cycle-safe "have I seen this?".WeakRef.deref()must be called once into a local and checked forundefined.FinalizationRegistryis a best-effort notification, not a destructor. Never depend on it.- For deterministic cleanup use
Symbol.disposeandusing, which is the next lesson.