skip to content

A browser SPA keeps `const rowMeta = new Map()` at module scope, keyed by the `<tr>` elements it renders, and adds an entry every time a table is drawn. Why does this grow without bound as the user navigates, and does switching it to a `WeakMap` fix it?

level: middleimportance: should knowfreq 40%

answer

  1. strong keys, no eviction policy
  2. element keys drag whole tables along
  3. weak keys let entries disappear
  4. only objects can be weak keys
  5. string-keyed caches still need a bound

basics

~20 s

A Map holds its keys strongly, so every row element ever rendered stays reachable through the module-level Map and its whole table with it. A WeakMap holds keys weakly and does fix this case, but only for object keys and only if nothing else still references the element.

solid answer

~50 s

`Map` keeps a strong reference to every key and value, so the elements can never be collected while the module lives — and because each `<tr>` points at its parent table, the entire detached table is retained too. The map only ever grows. Swapping in a `WeakMap` fixes exactly this shape: its keys are held weakly, so once an element is unreachable everywhere else the entry disappears on its own, with no teardown code. The caveats matter in an interview. `WeakMap` keys must be objects (non-registered symbols became legal in ES2023), so a cache keyed by a URL string cannot use one — that needs an explicit bound, an LRU or a TTL. `WeakMap` is not enumerable and has no `size`, which is the price of weakness. And it fixes nothing if something else still pins the element, such as an array of rendered rows.

code

javascript · 13 lines
javascript
const strong = new Map();
const weak = new WeakMap();

let row = document.createElement('tr');
strong.set(row, { height: 32 });
weak.set(row, { height: 32 });

row = null; // the only other reference is gone

// The Map still references the element, so it can never be collected:
console.log(strong.size); // 1
// The WeakMap entry is now eligible for collection; it has no size to report.
console.log('size' in weak); // false

go deeper

for a junior

Know that a Map keeps everything you put into it until you delete it, and that using DOM elements as keys keeps those elements in memory. Say that a WeakMap holds its keys weakly.

for a middle

Explain both faults — strong keys and no eviction — and why element keys amplify the cost by retaining detached trees. State the WeakMap constraint that keys must be objects, and that it has no size and no iteration.

for a senior

Show judgment about which structure fits: weak for derived side data whose lifetime should follow someone else's object, bounded and inspectable for a cache you must invalidate. Point out that weakness is useless while another array pins the same elements.

for a principal

Own the policy: every long-lived cache in the codebase declares a bound and an invalidation trigger, and that requirement is reviewable. Be ready to argue why unobservable weak structures are a poor default for anything you will later need to debug or size.

## The mechanism A `Map` is a strong container in both directions: it references its keys and its values, and the map itself is referenced by the module scope, which lives as long as the page. The reachability chain is therefore module → map → `<tr>` → parent table → every other row and cell in that table. Nothing in that chain is ever broken by rendering a new table or navigating to another view, so each render adds another permanently retained table. This is the canonical unbounded-cache leak: the code never does anything wrong at the moment it runs, it simply never removes anything. Two separate faults are stacked here, and a strong answer separates them: 1. **Unbounded growth** — the collection has no eviction policy at all. 2. **Amplification** — the keys are DOM elements, so each retained entry drags a whole detached tree with it. A map of a thousand small metadata objects is a nuisance; a map of a thousand table rows is megabytes. ## What WeakMap changes A `WeakMap` holds its keys weakly. If the only reference to an element is its presence as a `WeakMap` key, the collector is free to reclaim it, and the entry vanishes with it. That is precisely the semantics you want for "side data attached to an object I do not own the lifetime of": the cache follows the object rather than outliving it, and you write no teardown code at all. The semantics are ephemeron-based, which handles the obvious trap: if the *value* references its own key, the entry is still collectable. A plain `Map` in that situation would be an immortal cycle from the map's perspective; a `WeakMap` is not. ```js const meta = new WeakMap(); function remember(row) { meta.set(row, { row, renderedAt: Date.now() }); // value points back at the key } // still collectable once `row` is unreachable elsewhere ``` ## What WeakMap does not fix - **Non-object keys.** `WeakMap` keys must be objects — or, since ES2023, non-registered symbols. A response cache keyed by a URL string, a user id or a query hash cannot be weak. That case needs a real policy: a maximum size with LRU eviction, a TTL, or clearing on route change. - **Keys pinned elsewhere.** If the app also holds `const renderedRows = []` containing the same elements, the `WeakMap` is irrelevant — the array keeps them alive and the weak entry with them. Weakness fixes retention *by the cache*, not retention in general. - **Values that reach roots.** The value is held as strongly as the key is reachable. A value holding a subscription or a large buffer still occupies memory for as long as its key lives. - **Observability.** `WeakMap` has no `size`, no iteration, no `clear()`. You cannot inspect it, count it, or write a test that asserts it is empty — which is exactly why it cannot leak, and also why it is a poor choice when you need to know what is cached. ## Choosing between the two Use `WeakMap` (or `WeakSet`) when the entry is **derived data whose lifetime should match an object someone else owns**: metadata for a DOM node, a parsed form of a third-party object, a "have I already processed this" marker. Use a bounded strong structure when the cache is **the point** — a request cache, a memoization table keyed by primitives, anything you need to inspect, size, or invalidate deliberately. "Bounded" means a written-down policy: a cap plus an eviction rule, or a clear on some lifecycle event. ## WeakRef and FinalizationRegistry ES2021 added `WeakRef` (a weak pointer you `deref()`, which may return `undefined`) and `FinalizationRegistry` (a callback after an object is collected). They cover cases a `WeakMap` cannot, such as a weakly held *value* rather than key. Both come with a strong warning in the specification: collection timing is not guaranteed and callbacks may never run, so nothing correctness-critical may depend on them. In practice they are diagnostics and caches, not resource management — closing a socket in a `FinalizationRegistry` callback is a bug, and interviewers listen for whether you know that. ## The answer that lands Name the leak (strong keys plus no eviction), name the amplifier (element keys retain detached trees), say `WeakMap` is the right fix for element-keyed side data, and then volunteer the boundary: string-keyed caches still need an explicit size or time limit, because no amount of weakness helps a key the program keeps re-creating.

  • If the value stored in a WeakMap references its own key, does the entry leak?
    No. WeakMap entries have ephemeron semantics: the value is only treated as reachable while the key is reachable by some other path. A value pointing back at its key therefore does not keep the pair alive, which is the difference from a plain Map, where such a cycle would be retained forever by the map's own strong references.
  • What would you use to cache API responses keyed by URL, given that a WeakMap cannot take string keys?
    A Map with an explicit policy — a maximum entry count with LRU eviction, a TTL, or both — plus a deliberate invalidation point such as route change or logout. The important part is that the bound is written down and testable; an unbounded response cache in a long-lived tab is the same leak in a different shape, just without the detached DOM amplification.
  • Why is closing a WebSocket inside a FinalizationRegistry callback a bug?
    Because the specification guarantees nothing about when — or whether — those callbacks run. Collection may be deferred indefinitely, and callbacks may be skipped entirely when a page is discarded. Treat FinalizationRegistry as a diagnostic that tells you something was collected, and release real resources explicitly in teardown.

saying these in an interview costs you the question

  • Thinks a Map drops entries when the key element is removed
  • Claims WeakMap works with string or number keys
  • Believes WeakMap guarantees prompt or immediate collection
  • Says WeakMap fixes a leak even while an array holds the same elements
  • Uses FinalizationRegistry to release sockets or file handles

context