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.
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
// 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);
}
// 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
// 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:
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 theMap-based registry, you should see one detached element per unmounted row that was never explicitly deleted. With theWeakMap-based registry (and correctSetbookkeeping 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 listWeakMapinstances, 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 separateMaporWeakMapentry has to be cleaned up by your own code, in the same function, every time. - Migrating the primary registry to
WeakMapbut leaving a secondaryMapelsewhere (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+Sethybrid by default "just in case" enumeration is needed later. If nothing in the codebase currently needs to list observed elements, a plainWeakMapis simpler and carries zero risk of theSet-side leak. Add theSetonly 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
WeakMapby a string ID extracted from the element instead of the element itself. This defeats the entire purpose — a string is a primitive, cannot be aWeakMapkey at all, and reintroduces manual ID bookkeeping. Always key by theElementreference 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.
Related
- WeakMap Observer Registries — the full shared-observer and WeakMap registry pattern this page builds on
- Virtual List Windowing with Observers — the mount/unmount cycle that makes this leak visible at scale
- Preventing Memory Leaks in Long-Running Observers — closure capture and detached-subtree retention beyond the registry itself
- Optimizing IntersectionObserver for 1000 List Items — scaling the shared-observer pattern to large lists
↑ Back to WeakMap Observer Registries