A single shared IntersectionObserver dispatching to hundreds of elements is only as memory-safe as the structure that maps each entry back to its per-element handler. This guide covers the WeakMap-backed observer registry pattern — the standard technique for keeping that Element-to-metadata association without holding elements in memory after they leave the DOM — along with WeakSet, WeakRef, and FinalizationRegistry, the three related weak-reference primitives you will reach for in adjacent situations.

Concept Framing

Observer callbacks are usually written one of two ways. The first is a dedicated observer instance per element, with the handler captured directly in the constructor's closure. This works for a handful of elements but does not scale: creating one observer per element wastes memory compared to a single shared instance, and a large virtualized list attaching one closure-bound observer per row measurably increases heap pressure and setup time.

The second approach — the one this guide focuses on — uses one shared observer for every target that shares the same configuration, and a registry that maps each observed Element back to its metadata: the callback to invoke, any per-element configuration overrides, and whatever local state the handler needs (a "has fired once" flag, an animation timeline reference, an accumulated visibility duration). The shared callback receives a batch of IntersectionObserverEntry objects, looks each one up in the registry by entry.target, and dispatches accordingly.

The naive way to build that registry is a plain Map<Element, Metadata>. A Map keeps a strong reference to every key it holds. If an element is removed from the DOM — a route change, a list item scrolled out of a virtualized window, a modal closing — but the Map entry is never explicitly deleted, the element and everything reachable through it (child nodes, attached listeners, framework fiber state) stays resident on the JavaScript heap. This is exactly the detached-node retention pattern documented in observer lifecycle and memory management: the DOM tree forgets the node, but the JavaScript heap does not, because something still points to it.

WeakMap solves this by holding its keys weakly. A WeakMap<Element, Metadata> entry does not, by itself, keep the element alive. Once no other strong reference to that element exists anywhere in the program — typically once it is both removed from the DOM and no longer captured in a closure or another data structure — the garbage collector can reclaim the element, and the WeakMap entry for it silently disappears along with it. You never see this happen; there is no event, no callback (that is what FinalizationRegistry is partially for, with caveats below), and no way to enumerate what has been collected. The registry simply stops answering for keys that no longer exist.

Retention Comparison: Map vs WeakMap Holding Observer Targets Two side-by-side panels. Left panel: a Map holds a strong reference to an Element after it is removed from the DOM, so the element and its subtree remain reachable and stay on the heap. Right panel: a WeakMap holds the same kind of Element as a key with only a weak reference, so once the element is removed from the DOM and no other strong references exist, the garbage collector reclaims it and the WeakMap entry disappears automatically. Map<Element, Metadata> Map instance registry.set(el, meta) strong ref Element removed from DOM strong ref Subtree + closures still reachable Never garbage collected until delete(el) is called explicitly in application code WeakMap<Element, Metadata> WeakMap instance registry.set(el, meta) weak ref Element removed from DOM Subtree + closures reclaimed Reclaimed automatically once no other strong reference to the element remains

Two adjacent primitives round out the toolkit. WeakSet behaves like WeakMap but stores no value — only membership — so it fits presence checks like "have I already attached an observer to this element?" without needing a metadata object. WeakRef goes the opposite direction: it lets you hold an indirect handle to an object outside of a Map or WeakMap structure, one that does not itself prevent collection, so you can later call .deref() to check whether the object still exists — useful for diagnostic tooling that watches elements without owning their lifecycle. FinalizationRegistry complements WeakRef by letting you register a callback that the engine may invoke sometime after an object becomes unreachable, which is discussed with its important caveats below.

Why not just attach metadata directly to the element?

A tempting shortcut is skipping the registry entirely and stashing state directly on the element — el.dataset.observerState = JSON.stringify(state), or a non-standard custom property like (el as any)._observerMeta = state. Both approaches avoid a separate data structure, but they trade one problem for two others. dataset only stores strings, so any non-trivial metadata (a callback function, a Set, nested config) must be serialized and re-parsed on every read, which is both slow and lossy — functions cannot round-trip through JSON.stringify at all. A raw custom property avoids serialization but pollutes the DOM node's own object shape, which can deoptimize V8's hidden-class machinery if different elements end up with different property sets, and it leaks the moment the element is cloned with cloneNode(), since the clone does not carry expando properties written outside the standard DOM API. A WeakMap keyed by the element sidesteps all three problems: it stores real object references and functions natively, it never touches the element's own shape, and it does not survive a cloneNode() copy — which is correct, since a clone is a distinct element that has not been registered yet.

When the shared-registry pattern actually pays off

For a handful of elements — a dozen or so cards on a landing page — the difference between one observer per element and a shared observer with a WeakMap registry is not measurable. The pattern earns its complexity at scale: virtualized lists, infinite-scroll feeds, and dashboards that mount hundreds of sentinel elements simultaneously. In those cases, one observer instance amortizes the browser's internal per-observer bookkeeping across every target, and the WeakMap lookup — an O(1) operation — replaces what would otherwise be hundreds of separate closures, each retaining its own captured scope. The callback-throttling guide's coverage of large lists measures this directly: heap snapshots of 1,000 closure-per-element observers versus one shared observer with a WeakMap registry show a multi-megabyte difference in retained size purely from closure scope duplication, before any DOM node retention is even considered.

Spec / Signature Reference Table

Structure Key / target type Holds a value? Iterable / has size Key reference strength Typical role in observer code
Map<K, V> Any value Yes Yes — keys(), values(), entries(), size Strong Registries you must enumerate; risks leaking detached elements
WeakMap<K, V> Object or non-registered Symbol only Yes No — no size, not iterable Weak Element → metadata registry for a shared observer callback
Set<T> Any value No (value is the entry) Yes — iterable, size Strong Tracking a small, explicitly-managed group of live keys
WeakSet<T> Object only No No — not iterable Weak Dedup / "already observed" membership checks without metadata
WeakRef<T> Object only N/A (wraps one target) N/A Weak (via .deref()) Holding an indirect handle to an element outside its owning registry
FinalizationRegistry Any registered object Held value delivered to callback N/A Observes collection, does not prevent it Diagnostic logging when a tracked object is reclaimed

Read the table as a decision ladder rather than a feature comparison: start from Map only if you have a hard requirement to enumerate every entry and are prepared to manage deletion yourself; drop to WeakMap the moment the keys are DOM elements whose lifetime you do not fully control. The Set/WeakSet row follows the same logic one level down — reach for Set only when you are certain every entry will be removed through a code path you own, and WeakSet whenever that certainty is not guaranteed, which in practice means almost always for DOM-facing code. WeakRef and FinalizationRegistry sit outside this ladder entirely: they are not registries at all, but narrow tools for holding or observing a reference without participating in its ownership.

Step-by-Step Implementation

Step 1 — Create the shared observer and the WeakMap registry

TypeScript
// weakmap-observer-registry.ts
interface ObservedMeta {
  onEntry: (entry: IntersectionObserverEntry) => void;
  once?: boolean;
}

// One observer instance serves every target registered below.
const registry = new WeakMap<Element, ObservedMeta>();

const sharedObserver = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    const meta = registry.get(entry.target);
    if (!meta) continue; // target was unregistered or already collected
    meta.onEntry(entry);
    if (meta.once && entry.isIntersecting) {
      unregister(entry.target);
    }
  }
}, { threshold: 0.1, rootMargin: '0px' });

Step 2 — Register a target with its metadata

TypeScript
function register(target: Element, meta: ObservedMeta): void {
  registry.set(target, meta);
  sharedObserver.observe(target);
}

// Usage
register(document.querySelector('#promo-card')!, {
  onEntry: (entry) => {
    if (entry.isIntersecting) entry.target.classList.add('visible');
  },
  once: true,
});

Step 3 — Dispatch through the registry, not through closures

Because the callback above looks up entry.target in registry rather than capturing a per-element closure, adding the one-thousandth element costs one WeakMap.set() call and one observe() call — no new function allocation, no new observer instance.

Step 4 — Unregister deterministically

TypeScript
function unregister(target: Element): void {
  sharedObserver.unobserve(target);
  registry.delete(target); // explicit — do not depend on GC timing
}

// Full teardown, e.g. on route change
function teardownAll(targets: Iterable<Element>): void {
  for (const target of targets) unregister(target);
  sharedObserver.disconnect();
}

Calling registry.delete(target) is not strictly required for the WeakMap entry to eventually vanish — it will, once the element itself is unreachable — but it is required for deterministic cleanup. If application code still references the element elsewhere (a framework ref, a closure, another array), the WeakMap entry survives indefinitely regardless of delete(). Explicit deletion during unobserve() is the only cleanup path you can reason about.

Configuration Variants

Variant: WeakMap + Set of live keys, for cases that need enumeration

WeakMap cannot be iterated, which is a problem the moment you need to answer "disconnect everything currently observed" without a separate list of targets. The fix is to keep a parallel Set<Element> of live keys alongside the WeakMap of metadata:

TypeScript
const meta = new WeakMap<Element, ObservedMeta>();
const liveTargets = new Set<Element>(); // strong refs — must be cleared explicitly

function register(target: Element, m: ObservedMeta): void {
  meta.set(target, m);
  liveTargets.add(target);
  sharedObserver.observe(target);
}

function unregister(target: Element): void {
  sharedObserver.unobserve(target);
  meta.delete(target);
  liveTargets.delete(target); // must remove or this Set leaks like a plain Map would
}

function disconnectAll(): void {
  for (const target of liveTargets) sharedObserver.unobserve(target);
  liveTargets.clear();
  sharedObserver.disconnect();
}

Note the trade-off: liveTargets holds strong references, so it reintroduces the exact leak risk a WeakMap avoids unless every unregister() call also removes the element from the Set. This pattern is covered in more depth, with heap-snapshot evidence, in WeakMap vs Map for observer target tracking.

Variant: WeakSet-only registry for dedup checks

When you don't need metadata — only "has this element already been wired up?" — a WeakSet is lighter than a WeakMap:

TypeScript
const alreadyWired = new WeakSet<Element>();

function attachOnce(target: Element): void {
  if (alreadyWired.has(target)) return;
  alreadyWired.add(target);
  sharedObserver.observe(target);
}

Variant: FinalizationRegistry for leak auditing (development only)

TypeScript
// Development-only diagnostic — never gate production logic on this firing.
const leakAudit = new FinalizationRegistry<string>((heldLabel) => {
  console.debug(`[observer-audit] GC reclaimed target: ${heldLabel}`);
});

function registerWithAudit(target: Element, label: string, m: ObservedMeta): void {
  registry.set(target, m);
  sharedObserver.observe(target);
  leakAudit.register(target, label); // does not keep target alive
}

If label never logs after a component that should have unmounted its targets, that is a signal — though not proof — that something else still references the element. Treat this purely as a development aid; see the edge cases below for why it cannot anchor production correctness.

Variant: WeakRef for indirect diagnostics handles

TypeScript
// Hold an indirect handle without preventing collection.
const watchedRefs: WeakRef<Element>[] = [];

function watch(target: Element): void {
  watchedRefs.push(new WeakRef(target));
}

function reportLiveCount(): number {
  return watchedRefs.filter((ref) => ref.deref() !== undefined).length;
}

Choosing between the variants

Need Reach for
Per-element callback + config, no enumeration required Plain WeakMap registry
Per-element callback + config, plus "disconnect everything" support WeakMap + parallel Set of live keys
Only "has this element already been handled?" WeakSet, no metadata
Development-time leak auditing, non-blocking FinalizationRegistry alongside the primary registry
An indirect, non-owning handle to check later WeakRef

Edge Cases & Gotchas

WeakMap and WeakSet keys must be objects. Passing a string, number, boolean, null, or undefined as a key throws TypeError: Invalid value used as weak map key. This is rarely an issue with DOM elements, but it surfaces when code tries to key a registry by an element's id string instead of the element reference itself — at that point you no longer have a weak reference to anything, and you are back to manual string-based bookkeeping.

No iteration, no size, ever. This is intentional, not a missing feature. Exposing enumeration on a weakly-held collection would let application code observe collection timing indirectly (by watching entries disappear), which the specification avoids for both determinism and security reasons. Any code that needs to answer "what is currently being observed" needs the Set-of-live-keys variant above.

FinalizationRegistry timing is unspecified and unreliable. The callback may run long after the target becomes unreachable, may be batched arbitrarily, may run zero times if the page unloads first, and is explicitly permitted by spec to never run under memory pressure or short-lived scripts. Some engines also suppress or delay collection while DevTools' Memory panel is open, which makes finalization callbacks misleading during active debugging. Never gate unobserve(), disconnect(), or any user-visible behavior on a FinalizationRegistry callback firing.

A WeakMap only helps if it is the only thing holding a strong reference. Swapping a Map for a WeakMap in one registry does not fix a leak if the same element is still referenced by a plain array, a framework ref map, or a closure captured elsewhere in the codebase. Preventing memory leaks in long-running observers documents exactly this failure mode: switching the observer registry to WeakMap while a separate analytics module still holds the same elements in a Map produces no measurable improvement in heap snapshots.

WeakRef.deref() can flip from an object to undefined between any two calls. The specification guarantees an object stays alive for the remainder of the current synchronous job once you have successfully dereferenced it, but nothing beyond that. Cache the dereferenced value once per callback invocation rather than calling .deref() repeatedly and assuming stability across await boundaries or subsequent event loop turns.

Browser support gates WeakRef and FinalizationRegistry, not WeakMap/WeakSet. WeakMap and WeakSet have shipped in every evergreen browser for years; WeakRef and FinalizationRegistry are newer (baseline since 2021) and unavailable in older WebViews. Feature-detect before using them, and consult the current browser compatibility matrix before shipping diagnostic tooling built on them.

A single element registered in two independent WeakMap registries is fine — the registries do not interfere. Because WeakMap entries are effectively ephemeron pairs stored per-map rather than properties attached to the key object, an element can appear as a key in a WeakMap used by your observer registry and simultaneously in an unrelated WeakMap used by a state-management library, a routing cache, or a third-party analytics script, with no coordination required and no risk of one registry's delete() affecting the other. The only shared constraint is that all of them must eventually release the element, or none of them will let it be collected.

Framework Integration Patterns

React — module-level shared registry

TypeScript
// observer-registry.ts — module scope, shared across every component instance
const registry = new WeakMap<Element, (entry: IntersectionObserverEntry) => void>();
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => registry.get(entry.target)?.(entry));
}, { threshold: 0.1 });

export function useSharedIntersection(
  ref: React.RefObject<Element>,
  onEntry: (entry: IntersectionObserverEntry) => void
): void {
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    registry.set(el, onEntry);
    observer.observe(el);
    return () => {
      observer.unobserve(el);
      registry.delete(el);
    };
  }, [ref, onEntry]);
}

Vue 3 — composable over the same shared registry

TypeScript
import { onMounted, onUnmounted, Ref } from 'vue';

export function useSharedIntersection(
  elRef: Ref<Element | null>,
  onEntry: (entry: IntersectionObserverEntry) => void
): void {
  onMounted(() => {
    if (!elRef.value) return;
    registry.set(elRef.value, onEntry);
    observer.observe(elRef.value);
  });
  onUnmounted(() => {
    if (!elRef.value) return;
    observer.unobserve(elRef.value);
    registry.delete(elRef.value);
  });
}

Angular — directive backed by a static registry

TypeScript
import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core';

@Directive({ selector: '[sharedIntersection]', standalone: true })
export class SharedIntersectionDirective implements OnInit, OnDestroy {
  @Input() sharedIntersection!: (entry: IntersectionObserverEntry) => void;
  private static readonly registry = new WeakMap<Element, (e: IntersectionObserverEntry) => void>();
  private static readonly observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => SharedIntersectionDirective.registry.get(entry.target)?.(entry));
  }, { threshold: 0.1 });

  constructor(private el: ElementRef<HTMLElement>) {}

  ngOnInit(): void {
    SharedIntersectionDirective.registry.set(this.el.nativeElement, this.sharedIntersection);
    SharedIntersectionDirective.observer.observe(this.el.nativeElement);
  }

  ngOnDestroy(): void {
    SharedIntersectionDirective.observer.unobserve(this.el.nativeElement);
    SharedIntersectionDirective.registry.delete(this.el.nativeElement);
  }
}

All three patterns share the same shape: one observer, one WeakMap, per-instance register/unregister calls tied to the framework's own mount and destroy hooks. This is the same shape used for virtualized list windowing, where thousands of rows share one registry and one observer instance rather than one of each per row.

Debugging Checklist

A WeakMap registry removes one specific leak vector — strong references held by the registry itself — but it cannot prove the absence of leaks elsewhere in the application. When heap growth persists after migrating to a WeakMap-backed registry, the fastest path to a root cause is a heap snapshot comparison, not more code reading. Use this sequence when you suspect the registry is not preventing the leak you expect:

  • Confirm unregister()
  • In the comparison view, filter by "Detached" — any detached HTMLElement still present means something other than your WeakMap
  • Search the heap for lingering Map or Array instances that also reference observed elements — a WeakMap
  • If using a FinalizationRegistry
  • Verify registry.delete(target) is called in the same code path as observer.unobserve(target)

FAQ

Can I use a plain object, string, or number as a WeakMap key?

Plain objects work fine as WeakMap keys, but strings, numbers, and booleans do not — WeakMap keys must be objects (or, since ES2023, non-registered Symbols) because the engine needs an identity it can track for garbage collection. A DOM Element qualifies automatically since it is an object. Passing a primitive throws Invalid value used as weak map key.

Does calling registry.delete(target) matter if WeakMap already collects unreachable keys?

Yes, it still matters. WeakMap only reclaims an entry once the key itself becomes unreachable from anywhere in the program, not just from the WeakMap. If your own code still holds a reference to the element elsewhere, the WeakMap entry stays alive indefinitely. Calling delete() explicitly during unobserve() guarantees immediate cleanup instead of depending on unpredictable GC timing.

Is a WeakSet different from a WeakMap when tracking observed elements?

WeakSet stores only membership — you can ask has(element) but cannot attach any metadata. Use WeakSet when you only need to know whether an element has already been observed or processed, such as deduplicating observe() calls. Use WeakMap when you need to associate a callback, configuration object, or state value with each element.

Can I rely on FinalizationRegistry to call disconnect() when an element is garbage collected?

No. The specification deliberately leaves FinalizationRegistry callback timing unspecified — the callback may fire seconds later, may be batched, or may never fire at all before the page unloads. It is a diagnostic and resource-hygiene tool, not a substitute for explicitly calling unobserve() or disconnect() in your framework's teardown hook.

Why can't I iterate a WeakMap to list all currently observed elements?

WeakMap has no keys(), values(), entries(), or size by design, because exposing enumeration would let code observe garbage collection timing indirectly — a determinism and security concern for the language. If you need to list or disconnect every observed element, pair the WeakMap with a separate iterable Set of live element references.


↑ Back to Performance Optimization & Memory Management for Observer APIs