Sometimes you need to attach extra data to a DOM node — a cached measurement, a parsed config, a "seen" flag — without writing it onto the element itself. The catch: when that node leaves the page and nothing else references it, your stored data must not keep it alive. Implement nodeRegistry(), which returns a small object that associates arbitrary values with DOM nodes and lets those nodes be garbage-collected once they are gone. The implementation detail that makes this safe is a WeakMap, which holds its keys weakly.
function nodeRegistry(): {
set(node: Node, value: unknown): void; // associate value with node
get(node: Node): unknown; // value, or undefined if absent
has(node: Node): boolean; // is this node registered?
delete(node: Node): boolean; // remove; true if it existed
};
Keys are DOM nodes, compared by identity. Values can be anything. The registry exposes no way to list or count the nodes it holds.
const reg = nodeRegistry();
const a = document.createElement('div');
const b = document.createElement('div');
reg.set(a, { clicks: 1 });
reg.set(b, { clicks: 9 });
reg.get(a); // → { clicks: 1 } (each node keeps its own value)
reg.has(b); // → true
reg.delete(a); // → true
reg.get(a); // → undefined
const reg = nodeRegistry();
const node = document.createElement('span');
reg.get(node); // → undefined (never registered)
reg.set(node, 0);
reg.has(node); // → true (a stored 0 is still "present")
0, false, null — all valid. has reports presence, not truthiness.size, keys(), or forEach — exposing one would defeat the whole point.document.createElement and never inserted is a valid key.You'll build a tiny registry that maps DOM nodes to values, backed by a WeakMap so the nodes it tracks can still be garbage-collected once the page is done with them.
Imagine you're building a tooltip library. For each element a user hovers, you want to remember some state — the tooltip's last position, whether it's currently open. You need a place to stash that data keyed by the element. The danger: if your storage holds onto every element forever, then elements that get removed from the page can never be freed, and your library slowly eats memory the longer the page lives. You want a lookup table whose keys are DOM nodes, but one that lets go of a node automatically when the rest of the app has let go of it.
A normal collection (an array, a Map, a plain object) holds a strong reference to whatever you put in it: as long as the collection is alive, everything inside it is alive too. A WeakMap is different — it holds its keys weakly. A weak reference does not count as "keeping the object alive." So if a node exists only as a WeakMap key and nowhere else, the garbage collector is free to reclaim it, and the entry quietly disappears with it.
The obvious instinct is to reach for a plain object as the lookup table — store[node] = value:
function nodeRegistry() {
const store = {};
return {
set(node, value) { store[node] = value; },
get(node) { return store[node]; },
has(node) { return Object.prototype.hasOwnProperty.call(store, node); },
delete(node) {
if (!Object.prototype.hasOwnProperty.call(store, node)) return false;
delete store[node];
return true;
},
};
}
This looks reasonable but is broken at the most basic level. Object keys can only be strings (or symbols). When you write store[node], JavaScript coerces the node to a string by calling its toString() — and every <div> produces the same string, "[object HTMLDivElement]". So two different <div> nodes map to one key: set(b, "B") overwrites set(a, "A"), and get(a) returns "B". Every element of the same type collides into a single slot.
function nodeRegistry() {
// A WeakMap keys by object IDENTITY and holds its keys WEAKLY: once a node is
// gone from the DOM and unreferenced everywhere else, the entry can be
// garbage-collected. No node list is ever exposed, so nothing pins the nodes.
const store = new WeakMap();
return {
set(node, value) {
store.set(node, value);
},
get(node) {
return store.get(node);
},
has(node) {
return store.has(node);
},
delete(node) {
return store.delete(node);
},
};
}
module.exports = { nodeRegistry };
Two shifts make it correct. First, a WeakMap compares keys by identity (a === b), not by a stringified form — so two different nodes are always two different entries, and the collision is gone. Second, because its keys are weak, the registry never becomes the thing that keeps a node alive: you get the lookup table without the leak. The four methods are thin pass-throughs, and notice what we don't return — no size, no forEach, no keys(). A WeakMap deliberately offers no way to enumerate its contents (you can't list keys that might vanish at any moment), and that absence is the feature: it guarantees we never hold an enumerable, strong list of nodes.
Take two fresh nodes and watch them stay distinct:
const reg = nodeRegistry() — store is a brand-new empty WeakMap.const a = document.createElement('div'), const b = document.createElement('div') — two separate objects; a === b is false.reg.set(a, 'A') — store.set(a, 'A'). The WeakMap records an entry keyed on the object a.reg.set(b, 'B') — store.set(b, 'B'). Because b is a different object, this is a second entry, not an overwrite.reg.get(a) — store.get(a) looks up by identity and returns 'A'. reg.get(b) returns 'B'. Each node kept its own value.a is removed from the DOM and every other reference to it is dropped. Nothing strong points at a anymore — the WeakMap key doesn't count — so the garbage collector can reclaim a, and its entry disappears. You wrote no cleanup code.The plain-object version fails at step 4: store[a] and store[b] are both the key "[object HTMLDivElement]", so step 4 overwrites step 3 and reg.get(a) returns 'B'.
store[node] stringifies the key, so every element of the same tag collides into one slot and overwrites each other. Fix: use a WeakMap (or Map), which key by identity, not by string.Map "because it also keys by identity." A Map works functionally — distinct keys, correct lookups — but it holds keys strongly, so every node you ever registered stays in memory forever even after it leaves the DOM. That's the subtle memory leak this question is about. Fix: WeakMap for node keys.has as return !!store.get(node) reports false for a node whose value is 0, false, or null. Fix: use store.has(node), which checks presence, not truthiness.size or forEach. A WeakMap intentionally has none, because its entries can vanish between two lines of code. If you find yourself wanting to enumerate the registry, you actually want a Map — and you've reintroduced the leak.Map vs WeakMap, made testable. You can't observe garbage collection from a unit test, but you can prove the interface difference: a leak-prone version exposes a size that keeps growing, while the safe version exposes no enumeration at all. That's what the "does not expose an enumerable collection" test pins down.WeakRef and FinalizationRegistry. When you need a weak reference to a value (not a key) or a callback that runs after a node is collected, the FinalizationRegistry API exists — with heavy caveats, since collection timing is never guaranteed.WeakMap keyed on the DOM node is the standard pattern for "extra data about an element that shouldn't outlive the element."Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Sometimes you need to attach extra data to a DOM node — a cached measurement, a parsed config, a "seen" flag — without writing it onto the element itself. The catch: when that node leaves the page and nothing else references it, your stored data must not keep it alive. Implement nodeRegistry(), which returns a small object that associates arbitrary values with DOM nodes and lets those nodes be garbage-collected once they are gone. The implementation detail that makes this safe is a WeakMap, which holds its keys weakly.
function nodeRegistry(): {
set(node: Node, value: unknown): void; // associate value with node
get(node: Node): unknown; // value, or undefined if absent
has(node: Node): boolean; // is this node registered?
delete(node: Node): boolean; // remove; true if it existed
};
Keys are DOM nodes, compared by identity. Values can be anything. The registry exposes no way to list or count the nodes it holds.
const reg = nodeRegistry();
const a = document.createElement('div');
const b = document.createElement('div');
reg.set(a, { clicks: 1 });
reg.set(b, { clicks: 9 });
reg.get(a); // → { clicks: 1 } (each node keeps its own value)
reg.has(b); // → true
reg.delete(a); // → true
reg.get(a); // → undefined
const reg = nodeRegistry();
const node = document.createElement('span');
reg.get(node); // → undefined (never registered)
reg.set(node, 0);
reg.has(node); // → true (a stored 0 is still "present")
0, false, null — all valid. has reports presence, not truthiness.size, keys(), or forEach — exposing one would defeat the whole point.document.createElement and never inserted is a valid key.You'll build a tiny registry that maps DOM nodes to values, backed by a WeakMap so the nodes it tracks can still be garbage-collected once the page is done with them.
Imagine you're building a tooltip library. For each element a user hovers, you want to remember some state — the tooltip's last position, whether it's currently open. You need a place to stash that data keyed by the element. The danger: if your storage holds onto every element forever, then elements that get removed from the page can never be freed, and your library slowly eats memory the longer the page lives. You want a lookup table whose keys are DOM nodes, but one that lets go of a node automatically when the rest of the app has let go of it.
A normal collection (an array, a Map, a plain object) holds a strong reference to whatever you put in it: as long as the collection is alive, everything inside it is alive too. A WeakMap is different — it holds its keys weakly. A weak reference does not count as "keeping the object alive." So if a node exists only as a WeakMap key and nowhere else, the garbage collector is free to reclaim it, and the entry quietly disappears with it.
The obvious instinct is to reach for a plain object as the lookup table — store[node] = value:
function nodeRegistry() {
const store = {};
return {
set(node, value) { store[node] = value; },
get(node) { return store[node]; },
has(node) { return Object.prototype.hasOwnProperty.call(store, node); },
delete(node) {
if (!Object.prototype.hasOwnProperty.call(store, node)) return false;
delete store[node];
return true;
},
};
}
This looks reasonable but is broken at the most basic level. Object keys can only be strings (or symbols). When you write store[node], JavaScript coerces the node to a string by calling its toString() — and every <div> produces the same string, "[object HTMLDivElement]". So two different <div> nodes map to one key: set(b, "B") overwrites set(a, "A"), and get(a) returns "B". Every element of the same type collides into a single slot.
function nodeRegistry() {
// A WeakMap keys by object IDENTITY and holds its keys WEAKLY: once a node is
// gone from the DOM and unreferenced everywhere else, the entry can be
// garbage-collected. No node list is ever exposed, so nothing pins the nodes.
const store = new WeakMap();
return {
set(node, value) {
store.set(node, value);
},
get(node) {
return store.get(node);
},
has(node) {
return store.has(node);
},
delete(node) {
return store.delete(node);
},
};
}
module.exports = { nodeRegistry };
Two shifts make it correct. First, a WeakMap compares keys by identity (a === b), not by a stringified form — so two different nodes are always two different entries, and the collision is gone. Second, because its keys are weak, the registry never becomes the thing that keeps a node alive: you get the lookup table without the leak. The four methods are thin pass-throughs, and notice what we don't return — no size, no forEach, no keys(). A WeakMap deliberately offers no way to enumerate its contents (you can't list keys that might vanish at any moment), and that absence is the feature: it guarantees we never hold an enumerable, strong list of nodes.
Take two fresh nodes and watch them stay distinct:
const reg = nodeRegistry() — store is a brand-new empty WeakMap.const a = document.createElement('div'), const b = document.createElement('div') — two separate objects; a === b is false.reg.set(a, 'A') — store.set(a, 'A'). The WeakMap records an entry keyed on the object a.reg.set(b, 'B') — store.set(b, 'B'). Because b is a different object, this is a second entry, not an overwrite.reg.get(a) — store.get(a) looks up by identity and returns 'A'. reg.get(b) returns 'B'. Each node kept its own value.a is removed from the DOM and every other reference to it is dropped. Nothing strong points at a anymore — the WeakMap key doesn't count — so the garbage collector can reclaim a, and its entry disappears. You wrote no cleanup code.The plain-object version fails at step 4: store[a] and store[b] are both the key "[object HTMLDivElement]", so step 4 overwrites step 3 and reg.get(a) returns 'B'.
store[node] stringifies the key, so every element of the same tag collides into one slot and overwrites each other. Fix: use a WeakMap (or Map), which key by identity, not by string.Map "because it also keys by identity." A Map works functionally — distinct keys, correct lookups — but it holds keys strongly, so every node you ever registered stays in memory forever even after it leaves the DOM. That's the subtle memory leak this question is about. Fix: WeakMap for node keys.has as return !!store.get(node) reports false for a node whose value is 0, false, or null. Fix: use store.has(node), which checks presence, not truthiness.size or forEach. A WeakMap intentionally has none, because its entries can vanish between two lines of code. If you find yourself wanting to enumerate the registry, you actually want a Map — and you've reintroduced the leak.Map vs WeakMap, made testable. You can't observe garbage collection from a unit test, but you can prove the interface difference: a leak-prone version exposes a size that keeps growing, while the safe version exposes no enumeration at all. That's what the "does not expose an enumerable collection" test pins down.WeakRef and FinalizationRegistry. When you need a weak reference to a value (not a key) or a callback that runs after a node is collected, the FinalizationRegistry API exists — with heavy caveats, since collection timing is never guaranteed.WeakMap keyed on the DOM node is the standard pattern for "extra data about an element that shouldn't outlive the element."Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.