JAVASCRIPT / MAPS, SETS, SYMBOLS, AND PROXIES
WeakMap and garbage-collected metadata
Attach metadata to objects with WeakMap so the data is collected with its key, and know why WeakMap has no size, no iteration, and no primitive keys.
What you will learn
- Store per-object metadata with WeakMap set/get/has/delete without mutating the object
- Explain why WeakMap has no size, no iteration, and rejects primitive keys
- Give class instances private state that is freed together with the instance
- Spot leaks where an array or Map pins keys the WeakMap was meant to release
Understanding WeakMap and garbage-collected metadata
A WeakMap is a lookup table whose keys must be objects and whose entries are not owned by the map. It exposes only get, set, has and delete: no size, no forEach, no iterator, no clear. The mental model is a note pinned to an object from the outside: the note can name the object, but it is not what keeps that object in memory, and once nothing else in the program can reach the object, the whole entry, key and value, becomes garbage.
The missing features follow from that guarantee rather than from an unfinished API. A primitive key is rejected because two occurrences of the string 'id' are one value with no identity and no lifetime, so an entry keyed by it could never be released. Listing entries is forbidden because entries disappear at moments only the collector chooses, so a size or an iterator would let a program detect whether collection had run yet, making memory behaviour observable and results dependent on memory pressure. The rules are coherent enough that a stored value may point back at its own key without keeping the entry alive.
Reach for a WeakMap when the data belongs to an object whose lifetime you do not control: DOM nodes, objects handed to your library by a caller, or instances of your own class that need state no property enumeration reveals. The discipline is that weak means this map adds no reference, not this map removes references; reachability is the union of every reference in the program, so a debugging array of the same keys or a stray listener closing over them pins keys and values alike. The value side is also strong while the key lives, so a WeakMap of large buffers keyed by long-lived objects still grows.
const lastSeen = new WeakMap();
const a = { id: 'a' };
const b = { id: 'b' };
lastSeen.set(a, 100);
lastSeen.set(b, 250);
console.log('a ->', lastSeen.get(a), '| b ->', lastSeen.get(b));
console.log('lookalike key:', lastSeen.has({ id: 'a' }));
console.log('object untouched:', JSON.stringify(a));
lastSeen.delete(a);
console.log('after delete:', lastSeen.has(a), lastSeen.get(a));
try {
lastSeen.set('a', 1);
} catch (err) {
console.log('primitive key:', err.constructor.name);
}
console.log('iterable?', Symbol.iterator in lastSeen, '| size?', 'size' in lastSeen);A WeakMap entry survives only while something else in the program can still reach its key object, which is why the map exposes no size, no iteration, and no primitive keys.
Worked examples
Per-instance state that no property reveals
Keying a WeakMap by this gives an object private data that dies with the instance.
const balances = new WeakMap();
class Account {
constructor(owner, start) {
this.owner = owner;
balances.set(this, start);
}
deposit(amount) {
balances.set(this, balances.get(this) + amount);
return this;
}
get balance() {
return balances.get(this);
}
}
const acct = new Account('Ada', 50);
acct.deposit(25).deposit(25);
console.log(acct.balance);
console.log(JSON.stringify(acct));
console.log(Object.getOwnPropertyNames(acct).join(','));
console.log(balances.get({ owner: 'Ada' }));Example explained
Line 1balances.set(this, start) files the number under the instance, so it never becomes a property of acct.
Line 2JSON.stringify and Object.getOwnPropertyNames see only owner, because the data lives outside the object they walk.
Line 3A hand-built lookalike is a different key, so get returns undefined: identity selects the entry, not shape.
Line 4When acct becomes unreachable, the instance and its balance entry become collectible together with no cleanup code.
Caching a derivation per object
A WeakMap memoizes results by object identity and stays blind to mutations of that object.
const cache = new WeakMap();
let runs = 0;
function summary(order) {
if (cache.has(order)) return cache.get(order);
runs++;
const total = order.items.reduce((sum, i) => sum + i.price * i.qty, 0);
const result = { total, count: order.items.length };
cache.set(order, result);
return result;
}
const order = { items: [{ price: 3, qty: 2 }, { price: 10, qty: 1 }] };
console.log(summary(order).total, summary(order).total, 'runs:', runs);
console.log('same result object:', summary(order) === summary(order));
order.items.push({ price: 5, qty: 1 });
console.log('after mutation:', summary(order).total, 'runs:', runs);
cache.delete(order);
console.log('after invalidation:', summary(order).total, 'runs:', runs);Example explained
Line 1The second call satisfies cache.has(order) and returns the stored object, so runs stays at 1.
Line 2=== is true because the WeakMap hands back the same result reference rather than recomputing an equal object.
Line 3Pushing an item cannot invalidate anything: the entry is keyed on identity and the map knows nothing about contents.
Line 4cache.delete(order) removes only the entry, forcing one recompute while leaving the order object untouched.
Why a Map here would leak
Shows that a Map keeps handing back objects the program has already dropped, while a WeakMap has no way to.
const strong = new Map();
const weak = new WeakMap();
let n1 = { name: 'n1' };
let n2 = { name: 'n2' };
for (const n of [n1, n2]) {
strong.set(n, { hits: 0 });
weak.set(n, { hits: 0 });
}
n1 = null;
n2 = null;
console.log('map size:', strong.size);
console.log('map still returns:', [...strong.keys()].map(k => k.name).join(','));
console.log('weakmap listing methods:', typeof weak.keys, typeof weak.forEach);Example explained
Line 1After n1 = null and n2 = null the program has no variable naming those objects, yet strong.size is still 2.
Line 2Spreading strong.keys() proves the Map is a live reference, and anything a container can return cannot be collected.
Line 3weak.keys and weak.forEach do not exist, so the WeakMap can never return a key, which is exactly what allows its entries to go.
Line 4In a long-running process the Map grows for the lifetime of the app while the WeakMap does not.
Important notes
Values are held strongly for as long as the key is reachable, so caching a multi-megabyte buffer under a long-lived object keeps that buffer alive just as long.
Since ES2023 a unique symbol can also serve as a WeakMap key, but a symbol from Symbol.for() is rejected because the global registry keeps it alive permanently.
Common mistakes
Using an id string or number as the key: set throws a TypeError immediately, because a primitive has no identity or lifetime for an entry to depend on.
Trying to inspect contents with console.log(wm) or [...wm]: the spread throws because there is no iterator, and Chrome's inspector showing entries is a debugger privilege your code does not have, so you can only reach values whose keys you still hold.
Setting a variable to null and then asserting the entry is gone: collection happens whenever the engine decides and no API reports it, so such a test fails at random.
Try it yourself
Change, predict, then run
On a page with three buttons, write setTip(el, text) and getTip(el) backed by a single WeakMap and call them for each element from document.querySelectorAll('button'). Confirm that el.outerHTML shows no new attribute and that getTip(document.createElement('button')) returns undefined.
Open the JavaScript workspaceCheck your understanding
A logger keeps const meta = new WeakMap() and calls meta.set(req, { startedAt }) for every request. A teammate also pushes each req into a module-level allRequests array for later inspection. What is the effect on memory?
- Every request object and its metadata stays alive as long as the array does, so the WeakMap no longer helps
- The metadata objects are collected but the request objects stay in the array as empty shells
- Nothing changes, because a WeakMap key is always collectible regardless of other references
- The WeakMap entries are dropped first and the array then holds keys with no metadata
Show answer
A WeakMap only promises not to add a reference of its own; reachability is the union of all references in the program, so the array keeps each req alive, and a reachable key keeps its WeakMap value alive too. The option claiming keys are always collectible reads weak as forcing collection, which no reference type can do; and options describing metadata vanishing while the object survives are impossible, since the entry is discarded only when the key becomes unreachable.