A Map<Element, Metadata> and a WeakMap<Element, Metadata> have identical get/set/has/delete signatures, which is exactly why teams miss the difference until a heap snapshot shows detached nodes piling up. This page works through the exact scenario where each structure wins, with the heap evidence to back it, and the one decision rule that resolves the choice every time.

Problem / Scenario Context

Picture a dashboard that renders a virtualized list of a few hundred rows, where each row registers itself with a shared IntersectionObserver to lazy-render its chart on scroll. The registration code stores a Map<Element, RowState> so the shared callback can look up each row's chart-rendering function by entry.target. As the user scrolls, rows above the fold unmount and new rows mount to replace them — standard virtualization behavior, covered in virtual list windowing with observers.

The unmount path calls observer.unobserve(row), but the team forgot the matching registryMap.delete(row) — an easy omission, since unobserve() sounds like it should be sufficient cleanup on its own. Because the Map holds a strong reference to row, that element and everything reachable from it stay on the JavaScript heap for the lifetime of the page, even though the row has been removed from the document and the observer has stopped watching it. After an extended scroll session, DevTools shows hundreds of detached DOM subtrees and a heap that only grows.

This class of leak is notoriously slow to surface in practice. A QA pass that scrolls a list twenty times and checks that the UI still looks correct will not notice anything wrong — the leaked rows are invisible, the scroll behavior is unaffected, and frame rates hold steady for a long time because a few hundred kilobytes of detached DOM does not register against a multi-hundred-megabyte heap. The problem only becomes visible after hours of real usage, in exactly the kind of long-lived dashboard or admin console session where a user might leave a tab open all day. By the time it reaches a bug report, the reproduction steps are vague ("the app gets slow after a while"), which is why establishing the heap-snapshot workflow below matters more than intuition for this category of bug.

Mechanics Explanation

The root cause is reference strength, not a missing method call. A Map's keys are ordinary strong references — as far as V8's garbage collector is concerned, a Map entry is no different from a variable holding the element or an array containing it. The mark-and-sweep collector walks from GC roots (global objects, the current call stack, active closures) and marks everything reachable as live. If registryMap is reachable from a GC root — which it is, being a module-level or component-level variable — then every key it holds is also reachable, DOM-detached or not.

WeakMap changes this at the reference level, not the API level. Its keys participate in what the specification calls an ephemeron relationship: the key-value pair is only considered reachable if the key is also reachable through some other strong reference path. Once the element is removed from the DOM and no other code holds it, the element becomes unreachable, and the garbage collector is permitted to reclaim both the element and its WeakMap entry in the same collection pass — with no delete() call required, though calling it anyway remains best practice for deterministic, immediate cleanup rather than waiting on GC scheduling.

Heap Growth Across Mount/Unmount Cycles: Map vs WeakMap Registry A bar chart comparing retained heap size after five mount and unmount cycles. The Map-based registry bars step upward with each cycle, showing accumulating retained elements. The WeakMap-based registry bars stay flat across every cycle, showing that elements are reclaimed after each unmount. Retained heap size after each mount/unmount cycle Cycle 1 → 5 1 2 3 4 5 Map — retained size climbs every cycle WeakMap — flat

The Decision Rule

Every case above collapses to a single question: is the key a DOM element (or any object) whose removal you do not fully control through a single, guaranteed code path? If yes, use WeakMap. If the answer is no — the keys are strings, numbers, or short-lived request identifiers that your own code always creates and always destroys in a matched pair — a plain Map is simpler and its strong references are not a liability, since nothing is going to detach those keys behind your back.

Observed elements almost always fail that test. Rows unmount on scroll, route changes tear down entire subtrees, error boundaries skip cleanup effects, and browser extensions or third-party scripts occasionally remove nodes your code never asked them to touch. Any of these paths can bypass a manual delete() call, and a Map offers no protection when they do. WeakMap is therefore the correct default for observer target registries specifically because the DOM's removal paths are numerous and not all of them run through code you control — not because Map is broadly unsafe as a data structure.

Comparison Table: Map vs WeakMap for Observer Target Tracking

Dimension Map<Element, Metadata> WeakMap<Element, Metadata>
Reference to the key element Strong — prevents GC on its own Weak — does not prevent GC
Entry survives unobserve() without explicit delete() Yes, indefinitely — this is the leak No — eligible for GC once the element is otherwise unreachable
Can enumerate all observed elements Yes — keys(), entries(), size No — must pair with a Set for enumeration
Safe default for DOM-element keys No, unless every teardown path is airtight Yes
Typical heap symptom when misused Detached elements accumulate across mount/unmount cycles None — the structure itself cannot cause this class of leak
Correct use case Registries you fully own and always drain (e.g., short-lived request maps keyed by string IDs) Any registry keyed by a DOM element whose removal is outside your direct control

Minimal Reproducible Example

TypeScript
// Leak-prone: strong Map reference outlives the removed element
const rowState = new Map<Element, { renderChart: () => void }>();

function mountRow(row: HTMLElement): void {
  rowState.set(row, { renderChart: () => drawChart(row) });
  sharedObserver.observe(row);
}

function unmountRow(row: HTMLElement): void {
  sharedObserver.unobserve(row);
  // BUG: missing rowState.delete(row) — row and its closure stay on the heap
}

// Simulate 200 mount/unmount cycles in a virtualized list
for (let i = 0; i < 200; i++) {
  const row = document.createElement('div');
  mountRow(row);
  unmountRow(row);
}
JavaScript
// Plain JS equivalent
const rowState = new Map();
function mountRow(row) {
  rowState.set(row, { renderChart: () => drawChart(row) });
  sharedObserver.observe(row);
}
function unmountRow(row) {
  sharedObserver.unobserve(row);
  // same bug — no rowState.delete(row)
}

After 200 cycles, rowState holds 200 entries whose keys are detached div elements — verifiable in DevTools by taking a heap snapshot, forcing GC, and searching for (detached) in the class filter.

Production-Safe Solution

TypeScript
// Leak-safe: WeakMap key does not block GC, even if delete() is forgotten
const rowState = new WeakMap<Element, { renderChart: () => void }>();

function mountRow(row: HTMLElement): void {
  rowState.set(row, { renderChart: () => drawChart(row) });
  sharedObserver.observe(row);
}

function unmountRow(row: HTMLElement): void {
  sharedObserver.unobserve(row);
  rowState.delete(row); // still best practice — immediate, deterministic cleanup
}

If the application also needs to answer "list every row currently registered" — for a debug overlay, or to force-disconnect everything on route change — pair the WeakMap with a Set that is drained in lockstep:

TypeScript
const rowState = new WeakMap<Element, { renderChart: () => void }>();
const liveRows = new Set<Element>(); // strong refs — must be cleared explicitly

function mountRow(row: HTMLElement): void {
  rowState.set(row, { renderChart: () => drawChart(row) });
  liveRows.add(row);
  sharedObserver.observe(row);
}

function unmountRow(row: HTMLElement): void {
  sharedObserver.unobserve(row);
  rowState.delete(row);
  liveRows.delete(row); // omitting this reintroduces the original Map leak
}

function disconnectAllRows(): void {
  for (const row of liveRows) sharedObserver.unobserve(row);
  liveRows.clear();
  sharedObserver.disconnect();
}

This hybrid is deliberate, not a compromise: the WeakMap remains the source of truth for metadata and degrades safely even if unmountRow() is skipped on some exotic teardown path, while the Set exists purely to support enumeration and must be maintained with the same discipline you would apply to a plain Map. The full registry pattern this builds on is covered in WeakMap observer registries.

Verification Steps

  • Baseline snapshot: Open DevTools → Memory → take a heap snapshot before any scrolling.
  • Stress the list: Scroll through enough rows to trigger 100–200 mount/unmount cycles, or run a scripted loop like the reproduction above.
  • Force GC: Click the trash-can icon in the Memory panel.
  • Comparison snapshot: Take a second snapshot and switch to "Comparison" view; sort by retained size.
  • Filter for (detached): With the Map-based registry, you should see one detached element per unmounted row that was never explicitly deleted. With the WeakMap-based registry (and correct Set bookkeeping if used), the detached count should return to zero after forcing GC.
  • Cross-check with the class filter for WeakMap: Chrome's heap snapshot does list WeakMap instances, but does not expose their weakly-held keys as retainers of the target elements — this is expected and confirms the weak relationship is doing its job.

Common Mistakes to Avoid

  • Assuming unobserve() also removes the registry entry. It does not — unobserve() only affects the observer's own internal target list. A separate Map or WeakMap entry has to be cleaned up by your own code, in the same function, every time.
  • Migrating the primary registry to WeakMap but leaving a secondary Map elsewhere (analytics, undo history, a debug panel) still indexing the same elements. The leak persists because the second structure is the one now holding the strong reference — the fix must cover every structure that stores the element, not just the observer registry.
  • Reaching for the WeakMap + Set hybrid by default "just in case" enumeration is needed later. If nothing in the codebase currently needs to list observed elements, a plain WeakMap is simpler and carries zero risk of the Set-side leak. Add the Set only when a real enumeration requirement appears, following the same discipline used to scale a shared observer to a thousand list items.
  • Trying to key a WeakMap by a string ID extracted from the element instead of the element itself. This defeats the entire purpose — a string is a primitive, cannot be a WeakMap key at all, and reintroduces manual ID bookkeeping. Always key by the Element reference directly.

FAQ

Will switching from Map to WeakMap fix every observer memory leak?

No. It only fixes leaks caused by the registry itself holding a strong reference to a removed element. If another data structure — a closure, an array, a second Map — still references the same element, that element stays on the heap regardless of what the observer registry uses. Audit every structure that stores element references, not just the observer registry.

How much heap does a leaked Map of observer targets actually cost?

It depends entirely on what each detached element retains — a bare div with no children costs little, but a card component with a mounted framework subtree, event listeners, and closed-over state can retain tens of kilobytes per instance. In a virtualized list that mounts and unmounts hundreds of rows per session, a Map-based registry with no explicit delete() can accumulate tens of megabytes over a single long session.

If WeakMap prevents leaks, why would I ever combine it with a Set?

WeakMap cannot be enumerated, so if your code needs to answer "what is currently observed" — for a bulk disconnect, a debug panel, or a periodic audit — a WeakMap alone cannot provide that list. Pairing it with a Set of the same element references restores enumeration, at the cost of reintroducing a strong reference that you must remove yourself in the same unregister step.

Can I use WeakMap for observer state that needs to survive a page reload?

No. WeakMap keys must be live object references, and there is no way to serialize an Element or reconstruct the same reference identity after a reload. WeakMap-based registries are strictly in-memory, per-session structures — persist any state that must survive a reload using a separate serializable store keyed by a stable string identifier instead.


↑ Back to WeakMap Observer Registries