“What is a WeakMap, and when would you use one instead of a Map?” Interviewers ask this to find out whether you understand memory management in a garbage-collected language. The common wrong answer is “a WeakMap is a faster or lighter Map.” The right answer is about lifetimes: who keeps an object alive, and what happens to cached data when the object it describes is no longer needed. A good follow-up asks why you cannot loop over a WeakMap or read its size, and the answer to that reveals whether you understand the design or just the API.
The topic often appears alongside memory-leak debugging questions, because a long-lived Map keyed by DOM nodes or request objects is a classic leak.
Before you start
You should know how Map works, that objects are passed by reference, and the basic idea of garbage collection: an object can be reclaimed once nothing reachable from the program’s roots (globals, the current call stack, closures) refers to it. You do not need to know how V8’s collector works internally.
The short answer
A Map holds strong references to its keys, so any object used as a key stays in memory as long as the map does, even if the rest of the program has forgotten it. A WeakMap holds its keys weakly: if the key object is not reachable any other way, the garbage collector can reclaim it and the entry, including its value, disappears. Keys must be objects (or non-registered symbols). Because entries can vanish at any moment, WeakMaps offer only get, set, has and delete, with no iteration and no size.
How it works
Think of reachability as a graph. A Map adds an edge from the map to each key, so keys stay reachable as long as the map is. A WeakMap does not add that edge. Its value is kept alive only while its key is alive, a relationship known as an ephemeron.
The following script forces garbage collection to make the difference visible. It enables the gc function from inside the script, which is only appropriate for experiments like this, never for production code.
import v8 from 'node:v8';
import vm from 'node:vm';
v8.setFlagsFromString('--expose-gc');
const gc = vm.runInNewContext('gc');
const strong = new Map();
const weak = new WeakMap();
let strongRef;
let weakRef;
(() => {
const a = { name: 'held by Map' };
const b = { name: 'held by WeakMap' };
strong.set(a, new Array(1e6).fill(0));
weak.set(b, new Array(1e6).fill(0));
strongRef = new WeakRef(a);
weakRef = new WeakRef(b);
})();
await new Promise((r) => setTimeout(r, 0));
gc();
console.log(strongRef.deref()?.name); // held by Map
console.log(weakRef.deref()?.name); // undefinedBoth key objects went out of scope when the arrow function returned. The one used as a Map key survived, along with its million-element array; the one used as a WeakMap key was collected, and its array with it. (The WeakRefs only observe; they do not keep anything alive. The setTimeout gives the engine a turn, since a WeakRef target is guaranteed to survive until the end of the current job.)
Garbage collection timing is up to the engine. In normal code you never know when an entry disappears, which is exactly why iteration and size are not offered: their results would depend on when the collector last ran.
const wm = new WeakMap();
console.log(typeof wm.size, typeof wm.keys, typeof wm.forEach); // undefined undefined undefined
try {
wm.set('text', 1);
} catch (e) {
console.log(e.constructor.name); // TypeError
}Primitive keys are rejected because primitives have no identity that can “die”: the string 'text' could always be recreated.
Step-by-step walkthrough
Step 1: Attach metadata to objects you do not own
When you need to associate extra data with objects created by other code (DOM nodes, framework objects, request objects), adding properties to them is intrusive and may clash. A WeakMap stores the data on the side, and it is cleaned up automatically with the object.
const clickCounts = new WeakMap();
function trackClick(element) {
clickCounts.set(element, (clickCounts.get(element) ?? 0) + 1);
}
const button = { id: 'buy' };
trackClick(button);
trackClick(button);
console.log(clickCounts.get(button)); // 2
console.log(Object.keys(button)); // [ 'id' ]The tracked object is untouched, and once button is removed from the page and dropped, its count goes with it.
Step 2: Cache derived data per object
Expensive computations derived from an object can be memoised in a WeakMap keyed by that object. The cache never outlives the inputs, so it cannot grow without bound as objects come and go.
const layoutCache = new WeakMap();
let computeCount = 0;
function measure(doc) {
if (!layoutCache.has(doc)) {
computeCount++;
layoutCache.set(doc, { words: doc.text.split(/\s+/).length });
}
return layoutCache.get(doc);
}
const doc = { text: 'weak maps tie cache lifetime to keys' };
measure(doc);
measure(doc);
console.log(measure(doc).words, computeCount); // 7 1One rule applies: the cache is only valid if the object does not change in ways that affect the result. If doc.text can be edited, invalidate with layoutCache.delete(doc) on edit, or key the cache on an immutable snapshot.
Step 3: Store private state
Before #private class fields existed, WeakMaps were the standard way to keep per-instance private data. The pattern still appears in older libraries and in code that needs privacy for objects it did not construct.
const secrets = new WeakMap();
class Account {
constructor(pin) {
secrets.set(this, { pin });
}
check(pin) {
return secrets.get(this).pin === pin;
}
}
const acct = new Account('1234');
console.log(acct.check('1234'), acct.check('0000')); // true false
console.log(JSON.stringify(acct)); // {}Step 4: Avoid holding the key inside its own value
A WeakMap value that strongly references its own key does not cause a leak, because ephemeron semantics handle that cycle. The leak comes from anything else holding the key strongly: a global array, a closure in a long-lived event listener, or a Map elsewhere. WeakMap only helps if it is the last thing referring to the key.
const meta = new WeakMap();
const registry = [];
function register(node) {
meta.set(node, { node, createdAt: 1 });
registry.push(node);
}
register({ id: 'n1' });
console.log(registry.length); // 1Here registry keeps every node alive forever, so the WeakMap entries can never be collected. Remove nodes from registry when they are disposed, or do not keep the array at all.
Worked scenario
A single-page app cached computed tooltip positions keyed by DOM element in a module-level Map. Each route change replaced hundreds of elements. Heap snapshots showed thousands of detached elements still in memory, retained by the cache, and the tab grew to over a gigabyte during a long session.
const positions = new Map();
function positionFor(el) {
if (!positions.has(el)) positions.set(el, { top: el.top + 10 });
return positions.get(el);
}
for (let route = 0; route < 100; route++) {
const elements = Array.from({ length: 200 }, (_, i) => ({ top: i }));
elements.forEach(positionFor);
}
console.log(positions.size); // 20000Every element from every past route is still a key. Changing one line, new Map() to new WeakMap(), lets each route’s elements be collected after they leave the page, together with their cached positions. Nothing else in the code needs to change, because the cache only ever used has, get and set.
const positions = new WeakMap();
function positionFor(el) {
if (!positions.has(el)) positions.set(el, { top: el.top + 10 });
return positions.get(el);
}
const el = { top: 5 };
console.log(positionFor(el).top, positions.has(el)); // 15 trueThe interview point: the fix is correct because the cached data has no meaning once its element is gone. If you needed to list or count cached entries for diagnostics, a WeakMap could not do it, and you would instead need explicit eviction, such as an LRU cache with a size limit.
Common mistake
- “WeakMap values are weak.” Only keys are weak. A value stays alive as long as its key does, even if nothing else references the value.
- “WeakMap frees memory immediately.” Collection happens whenever the engine decides. Never write logic that depends on an entry disappearing at a specific time.
- “WeakMap is a faster Map.” It is a different lifetime contract, not an optimisation.
- Using primitive keys. Strings and numbers throw a
TypeError. Use aMapor a plain object for primitive keys. - Expecting a WeakMap to fix a leak when something else holds the key. Any strong reference elsewhere keeps the entry alive.
- Confusing WeakMap with WeakRef.
WeakReflets you hold an object weakly and check later whether it still exists. It is lower level and harder to use correctly.
Verify the behavior
This test confirms the API constraints. The garbage collection behaviour itself is verified by the forced-GC script in “How it works,” since tests that depend on collection timing are inherently flaky.
import test from 'node:test';
import assert from 'node:assert/strict';
test('WeakMap accepts objects and rejects primitives', () => {
const wm = new WeakMap();
const key = {};
wm.set(key, 'v');
assert.equal(wm.get(key), 'v');
assert.equal(wm.has({}), false);
assert.throws(() => wm.set(1, 'x'), TypeError);
});
test('WeakMap exposes no size or iteration', () => {
const wm = new WeakMap();
assert.equal('size' in wm, false);
assert.equal(Symbol.iterator in wm, false);
});Run node --test weakmap.test.mjs; both tests pass. For the GC demo, run the earlier script with plain node; it enables gc itself.
Follow-up questions
What is WeakSet for? Tracking membership without retaining objects, for example marking which objects have already been processed or visited during a traversal.
What is FinalizationRegistry? It lets you register a callback that may run after an object is collected. The callback might run late or never, so it suits cleanup of external resources as a best effort, not program logic.
Why can symbols be WeakMap keys now? Since ES2023, non-registered symbols (made with Symbol(), not Symbol.for) can be weak keys, because they have unique identity that cannot be recreated.
How do you confirm a leak is fixed? Take heap snapshots in Chrome DevTools or Node’s inspector before and after repeating the action, and check that detached objects no longer accumulate and that their retainer path no longer leads through your cache.
Interview exercise
You maintain a library that adds a getStats(request) function. Stats are computed once per request object and requested several times while the request is handled. Requests are created by the web framework, and your library must not modify them or keep them alive after the response is sent. Implement getStats and explain your choice.
Answer and reasoning
const statsCache = new WeakMap();
let computed = 0;
function getStats(request) {
let stats = statsCache.get(request);
if (!stats) {
computed++;
stats = { headerCount: Object.keys(request.headers).length, path: request.url.split('?')[0] };
statsCache.set(request, stats);
}
return stats;
}
const req = { url: '/orders?page=2', headers: { host: 'x', accept: '*/*' } };
getStats(req);
console.log(getStats(req), computed); // { headerCount: 2, path: '/orders' } 1
console.log(Object.keys(req)); // [ 'url', 'headers' ]A WeakMap keyed by the request object meets every constraint. It does not modify the request (no extra properties), it computes stats once per request (the cache check), and it does not extend the request’s lifetime: once the framework drops the request after responding, the request and its cached stats become unreachable and can be collected. A Map would retain every request ever handled, a slow but certain memory leak in a long-running server. A plain object cannot use objects as keys at all. A strong answer also notes the assumption that headers do not change after the first call, and that you could not report “how many requests are cached,” which is an acceptable trade-off for this use.
Continue learning
Explore more JavaScript interview questions and test yourself with the JavaScript MCQs. Related notes: diagnosing JavaScript memory leaks and Map versus object for lookups. Official reference: WeakMap on MDN.