skip to content

You replace a leaking Map-based cache with a WeakMap keyed by the objects being cached. In which situations does the memory still not get released?

level: seniorimportance: should knowfreq 40%

answer

  1. weakness removes one edge, not all of them
  2. who else still points at the key
  3. key lifetime versus map lifetime
  4. values that reach other keys
  5. primitives were never eligible

basics

~20 s

Whenever something else still reaches the key: a long-lived key object, another strong collection holding the same objects, or a value in the map that references a different key. WeakMap also cannot help at all when the cache is keyed by strings or numbers.

solid answer

~60 s

A `WeakMap` only removes *its own* reference to the key; it cannot release anything that remains reachable another way. So the memory stays if the key objects are themselves long-lived — caching against a module singleton or a config object that never dies pins their entries for the process lifetime. It stays if the same objects are also held strongly somewhere else: a debug array, a listener list, an event-emitter registration, a second `Map`. It stays if a *value* in the map references a *different* object that is a key in the same map, because reading the live key would then have to yield that value, keeping the second entry alive too — a value referencing its own key is fine, since the specification requires engines not to treat a key as live merely for being referenced by its value. And a WeakMap does not apply at all when the cache is keyed by a request id string or a numeric key: primitives are rejected, so you need a `Map` with an explicit eviction policy — a size cap, a TTL, or deletion at a known lifecycle point.

code

javascript · 15 lines
javascript
const cache = new WeakMap();
const auditLog = [];            // the accidental strong holder

function render(req) {
  auditLog.push(req);           // pins req forever
  const html = `<p>${req.id}</p>`;
  cache.set(req, html);
  return html;
}

render({ id: 1 });
render({ id: 2 });
// auditLog keeps both request objects reachable,
// so neither cache entry can be collected.
console.log(auditLog.length);   // 2

go deeper

for a junior

Know that a WeakMap only drops its own reference to the key: if any other variable, array or collection still holds that object, nothing is released.

for a middle

Explain the reachability rule and the primitive-key limitation, and name the concrete alternative — a Map with a size cap or TTL — when the cache key is a string or number.

for a senior

Demonstrate diagnosis: reach for a heap snapshot, read the retaining path of a key that should have died, and identify the strong holder rather than speculating about collector timing.

for a principal

Own the caching policy as a design decision — key lifetime versus map lifetime, bounded value size, and who is accountable for eviction — so the choice survives contact with a long-running service instead of relying on weakness as a blanket guarantee.

## What swapping to WeakMap actually changes A `Map` cache leaks for one reason: the map holds a strong reference to every key, so nothing you ever cached can be collected while the map is alive. Switching to `WeakMap` removes that one edge from the object graph. It removes nothing else. An object is collectable when *no* path from a root reaches it, so the swap helps only if the map's reference was the last one. That framing is the answer to the whole question, and everything below is a case of it. ## Case 1 — the key itself is long-lived ```js const cache = new WeakMap(); function compile(config) { if (!cache.has(config)) cache.set(config, buildExpensiveThing(config)); return cache.get(config); } ``` If `config` is a module-level singleton created once at startup, its entry lives as long as the module does. Nothing is wrong with the code; the weakness simply has no work to do. Weak keying only pays when keys have shorter lifetimes than the map. Cache against per-request or per-instance objects and it works; cache against long-lived roots and you have written a `Map` with fewer features. ## Case 2 — a second strong reference elsewhere This is the one that survives code review and shows up in a heap snapshot weeks later: ```js const cache = new WeakMap(); const seenForDebug = []; // <- strong, forever function handle(req) { seenForDebug.push(req); // pins every request cache.set(req, render(req)); } ``` The array keeps every `req` reachable, so every cache entry stays too. The same happens with a listener registered on a long-lived emitter and never removed, a closure captured by a still-pending promise or an uncancelled timer, or a diagnostics `Map` added "temporarily". The diagnosis is always the same: take a heap snapshot and look at the retaining path of one of the keys — the retainer will be the strong holder, never the WeakMap. ## Case 3 — values that reference other keys Weak maps are specified with ephemeron semantics. The value is reachable *through* the key, so this self-reference is safe: ```js const wm = new WeakMap(); let node = {}; wm.set(node, { owner: node }); // value points back at its own key node = null; // entry is still collectable ``` The specification requires that a key is not treated as live merely because some value refers to it, so this cycle does not leak — engines resolve weak-map liveness iteratively rather than in one pass. What does leak is a value pointing at a *different* key of the same map: ```js wm.set(a, { next: b }); // while `a` is live, `b`'s entry is retained ``` While `a` is reachable, `wm.get(a)` must return a value that references `b`, so `b` stays reachable and `b`'s own entry stays too. Build a chain like this over a graph and one live root retains the entire structure. If you need to model relationships between keys, store identifiers or use a structure whose lifetimes you manage explicitly. ## Case 4 — the keys are primitives Most real caches are keyed by a URL, a SQL string, a request id, or a numeric id. `WeakMap` rejects all of them with a `TypeError`, and no wrapper fixes it: boxing the string into a fresh `new String(url)` produces a new object each call, so lookups never hit. Primitives have no identity the collector can track. For those caches you need a strong `Map` and a real eviction policy — a maximum size with LRU replacement, a TTL sweep, or deletion tied to a lifecycle event such as request completion. "Use a WeakMap" is not an eviction policy, and treating it as one is the single most common misuse. ## Case 5 — the value is the heavy part Even when the keys behave, weakness protects only the key's lifetime, not the value's size. A cache whose values are megabyte buffers holds those buffers for as long as their keys live, which may be much longer than the value is useful. Weak keying and value-size bounding are separate problems, and only the first is solved for you. ## How to diagnose it in practice When memory still grows after the swap, do not reason about the collector — measure. Take two heap snapshots under load, compare the retained sizes, pick a key object that should have gone, and read its retaining path. Either something strong appears in that path (cases 1, 2, 3), or the keys turn out never to have been objects at all (case 4). The answer to "why did WeakMap not fix my leak" is essentially always "because something else was still holding the key". ## What to say in the interview Lead with the principle — WeakMap drops one reference, and collection needs *all* references gone — then name the concrete cases. Adding that primitives cannot be keys, and that the fallback is a bounded `Map`, is what separates an answer that has been used in production from one that has been read about.

  • Does a WeakMap value that points back at its own key create a leak?
    No. Weak maps have ephemeron semantics: the specification requires that a key is not considered live merely because a value references it, so a self-referential pair is still collectable once nothing outside the map reaches the key. The dangerous shape is a value referencing a *different* key of the same map, which keeps that other entry alive.
  • How do you confirm which reference is keeping the keys alive?
    Take two heap snapshots in a profiler under representative load, find an object that should have been released, and read its retaining path. The path names the actual holder — a debug array, a listener, an uncancelled timer, a second Map. Reasoning about GC timing without a snapshot rarely finds it.
  • Your cache is keyed by URL strings. What is the right structure?
    A plain `Map` plus an explicit policy, because WeakMap rejects primitive keys and boxing them creates a new object per call that never matches. Bound it by size with LRU eviction, by age with a TTL, or delete entries at a known lifecycle point such as request completion — the lifetime is yours to manage.
  • Does using a WeakMap risk the cache being emptied while you still need it?
    Not in a way that can hurt you. An entry can only vanish once its key is unreachable, and if you can still perform the lookup you are holding the key by definition. What you lose is any guarantee about *when* entries go, which matters for measurement and testing, not correctness.

saying these in an interview costs you the question

  • Says WeakMap prevents all memory leaks
  • Wraps a string key in new String() to make it weakly cacheable
  • Claims a value referencing its own key leaks the entry
  • Thinks the entry is freed even though a listener still holds the key
  • Treats WeakMap as an eviction policy for a size-bounded cache

context