The advice to "use one observer for many elements" appears throughout this site — in lazy loading, in virtual lists, in scaling to a thousand rows. A pool is what turns that advice into something a whole codebase can follow without every feature reinventing it.

Concept Framing

The naive shape is one observer per component instance. It is the shape every tutorial teaches, because it is the shape that fits inside a single component file, and for a page with a handful of observed elements it is entirely fine.

It stops being fine when the count becomes dynamic. A feed that renders two hundred cards creates two hundred observers, each with its own internal observation list, each retaining a callback closure that captured the card element and usually a chunk of component state alongside it. Nothing here is catastrophic on its own — this is not a leak, and everything is released on unmount — but it is a large amount of machinery to maintain when a single instance would deliver the same information in one batched array.

A pool inverts the relationship. Instead of element owns observer, it becomes configuration owns observer, elements register with it. Two components that both want "fire at 50% visibility against the viewport" get the same instance, and neither needs to know the other exists.

The design has three parts:

  • A key derived from the observer options, so identical configurations collapse.
  • A registry mapping element to handler, so one shared callback can dispatch to many owners.
  • A reference count per instance, so the pool can release an observer nobody is using.

Many Callers, Few InstancesFive components requesting observation on the left. Three of them ask for the same configuration of no root, a 200 pixel rootMargin and a zero threshold; the other two ask for a half threshold. The pool in the middle holds exactly two observer instances keyed by those configurations, and a WeakMap of element to handler lets the shared callbacks dispatch back to the right owner.LazyImageLazyVideoPrefetchLinkImpressionCardRevealPanelnull|200px 0px|[0]1 instance, 3 targetsnull|0px|[0.5]1 instance, 2 targetsWeakMapelement → handlerFive callers, two instances. Add a sixth caller with an existing configuration and the instance count does not move.

Spec / Signature Reference Table

Pool member Signature Notes
observe(el, handler, opts) (Element, (e: IntersectionObserverEntry) => void, IntersectionObserverInit) => () => void Returns its own unregister function — callers never call unobserve directly
keyFor(opts) (IntersectionObserverInit) => string Root identity + rootMargin + sorted thresholds
size number Live instance count, for assertions and dev tooling
disposeAll() () => void Route-change teardown

Returning an unregister closure rather than exposing unobserve is the single most useful design decision in the whole pattern. It makes the reference count impossible to desynchronise, because the only way to stop observing is to call the function the pool handed you, and that function knows which instance it belongs to.

Step-by-Step Implementation

1. Derive a key that captures behaviour, and only behaviour

TypeScript
const rootIds = new WeakMap<Element | Document, number>();
let nextRootId = 1;

function rootKey(root: IntersectionObserverInit['root']): string {
  if (!root) return 'viewport';
  let id = rootIds.get(root);
  if (id === undefined) { id = nextRootId++; rootIds.set(root, id); }
  return `root#${id}`;
}

function keyFor(opts: IntersectionObserverInit = {}): string {
  const thresholds = Array.isArray(opts.threshold)
    ? [...opts.threshold].sort((a, b) => a - b)
    : [opts.threshold ?? 0];
  return `${rootKey(opts.root)}|${opts.rootMargin ?? '0px'}|${thresholds.join(',')}`;
}

The root needs an identity, not a serialisation — two different <div> elements would stringify identically. A WeakMap of element to a numeric id gives stable identity without retaining the element, which matters because a root that is removed from the document should not be kept alive by the pool's key table.

2. The pool itself

TypeScript
type Handler = (entry: IntersectionObserverEntry) => void;

interface Pooled { observer: IntersectionObserver; handlers: WeakMap<Element, Handler>; count: number; }

const pool = new Map<string, Pooled>();

export function observe(el: Element, handler: Handler, opts: IntersectionObserverInit = {}): () => void {
  const key = keyFor(opts);
  let slot = pool.get(key);

  if (!slot) {
    const handlers = new WeakMap<Element, Handler>();
    const observer = new IntersectionObserver((entries) => {
      // one shared callback; per-element behaviour comes from the registry
      for (const entry of entries) handlers.get(entry.target)?.(entry);
    }, opts);
    slot = { observer, handlers, count: 0 };
    pool.set(key, slot);
  }

  slot.handlers.set(el, handler);
  slot.count += 1;
  slot.observer.observe(el);

  let released = false;
  return () => {
    if (released) return;                 // idempotent: a double unmount must not double-decrement
    released = true;
    slot!.observer.unobserve(el);
    slot!.handlers.delete(el);
    if (--slot!.count === 0) { slot!.observer.disconnect(); pool.delete(key); }
  };
}

export function disposeAll(): void {
  for (const slot of pool.values()) slot.observer.disconnect();
  pool.clear();
}

export const poolSize = () => pool.size;

The released flag is not defensive padding. React Strict Mode, error boundaries and double-unmount paths all call cleanup functions more than once in practice, and a reference count that can go negative will disconnect an observer other components are still using.

3. Use it from a component

TypeScript
// React
function useInView(opts: IntersectionObserverInit = {}) {
  const [inView, setInView] = useState(false);
  const optsKey = keyFor(opts);                         // stable across renders
  const ref = useCallback((el: Element | null) => {
    cleanup.current?.();
    cleanup.current = el ? observe(el, (e) => setInView(e.isIntersecting), opts) : undefined;
  }, [optsKey]);                                        // NOT [opts] — see the caveat below
  const cleanup = useRef<(() => void) | undefined>(undefined);
  useEffect(() => () => cleanup.current?.(), []);
  return [ref, inView] as const;
}

Depending on optsKey rather than opts is the same identity problem described in why an inline options object rebuilds the observer — except that here the key is already computed, so the fix is free.

Reference Counting a Pooled InstanceA lifecycle across four moments. The first caller for a configuration creates the instance and takes the count to one. Two further callers raise it to three without constructing anything. As components unmount the count falls back through two and one. On reaching zero the pool disconnects the observer and removes the slot, so the next caller starts the cycle again.first callerconstruct, count 12 more registercount 3, still 1 instanceunmountscount 3 → 2 → 1count reaches 0disconnect + drop the slotWhy the release function must be idempotentStrict Mode, error boundaries and double unmounts all call cleanups twice.A count that can go negative disconnects an observer other callers still use.One configuration, many callers, one instance

Configuration Variants

Variant When Trade-off
Global module-scope pool Multi-page app, no client routing Simplest; nothing to tear down
Pool per route/page instance Single-page app Requires an explicit disposeAll on navigation
Pool with an idle timeout Configurations that churn Keeps an empty instance briefly in case it is re-requested
Separate pools per observer type Both intersection and resize pooling Entry shapes differ; a shared base class covers the bookkeeping
No pool Fewer than ~5 elements The indirection is not worth the lost directness

Edge Cases & Gotchas

The same element registered twice with different configurations. The pool handles this correctly — two instances, two WeakMap entries — but a naive registry keyed only by element would lose one handler. Keeping the handler map inside each slot rather than global is what makes this work.

rootMargin normalisation. '200px', '200px 0px' and '200px 0 200px 0' are equivalent to the browser but produce three different keys, so three instances. Normalising the string before keying — expand to four components — collapses them.

Threshold arrays with differing precision. [0, 0.5, 1] and [0, 0.50, 1.0] stringify identically after join, which is correct. [0.1 + 0.2] does not equal [0.3], which is a floating-point hazard worth rounding away with Number(t.toFixed(4)).

A handler that throws. One bad handler in a shared callback aborts the loop and starves every other element in the same batch. Wrapping the dispatch in a try/catch that logs and continues is worth the two lines.

Route changes in a single-page application. Without disposeAll, the pool retains one instance per configuration ever used across the session, each with its WeakMap and its callback. That is the case covered in disconnecting pooled observers on route change.

Testing. Because the pool is module state, it leaks between test files unless reset. Export disposeAll and call it in afterEach, alongside the observer mock cleanup described in testing observers in JSDOM.

Pooling ResizeObserver as Well

Everything above assumes IntersectionObserver, but the same structure applies to ResizeObserver with two adjustments worth stating explicitly, because getting them wrong produces bugs that look like the pool is broken when it is not.

The first is the key. A ResizeObserver takes no options at construction; the only configuration is the box argument passed to observe(), which is per-target rather than per-instance. That means a resize pool can share one instance for the entire application — every caller can register with it, regardless of which box they want — and the box choice travels with the registration rather than with the key. In practice a single module-level instance plus a WeakMap of handlers is the whole implementation, and the key machinery disappears.

The second is the guaranteed initial delivery. ResizeObserver fires once for every newly observed element, which is a feature: it is how a component gets its first measurement without a manual getBoundingClientRect. In a pooled world that initial entry arrives in the same batch as unrelated elements' entries, so a handler that assumes "the first time I am called, this is my initial measurement" is correct, but a handler that assumes "the first batch contains only my element" is not. Dispatching per entry rather than per batch, as the pool does, makes this a non-issue — which is one more reason the shared callback should never do work that spans entries.

There is a third, subtler benefit to a single resize instance. Because the browser delivers all observations for one instance in a single callback, pooling naturally batches: a container resize that affects thirty cards produces one callback with thirty entries rather than thirty callbacks. That in turn makes it straightforward to apply the read-then-write batching discipline across all thirty at once, which is not possible when each card owns its own observer and each callback runs independently. Pooling is therefore not only a memory optimisation for resize work — it is what makes the layout optimisation available at all.

Mixing the two pools behind one façade is tempting and usually a mistake. The entry shapes differ, the teardown semantics differ, and the key derivation differs; a shared base class holding the reference counting and the release-function pattern gives you the reuse without pretending the two APIs are interchangeable.

When Not to PoolThree situations where a pool is the wrong answer. A handful of observers with distinct configurations gains nothing and loses directness. A configuration used by exactly one element is already a one-to-one relationship. And a callback that genuinely needs per-element closure state is fighting the pattern rather than using it.Three cases where the indirection costs more than it savesFewer than about five observersThe construction and retention savings are immeasurable; the lost directness is not.A configuration used by exactly one elementAlready one-to-one. A pool adds a key, a map and a counter to manage nothing.A callback that must close over per-element stateFighting the pattern. If the state cannot live in a WeakMap, do not share the callback.

Framework Integration Patterns

React. One hook wrapping the pool, as above. The pool lives in a module, not in a context — a context provider adds a re-render surface for something that is not render state, and the pool has no per-tree configuration to vary.

Vue. A composable that calls observe in a watch on the target ref and stores the returned release function, calling it on both ref change and onBeforeUnmount, exactly as in Vue observer composables.

Angular. The pool is a natural injectable service with providedIn: 'root', and directives take it through the constructor. That also makes it trivially mockable in tests, and gives ngOnDestroy an obvious place to call the release function.

Debugging Checklist

  • poolSize() grows without bound. Release functions are not being called, or rootMargin variants are producing distinct keys for equivalent configurations.
  • An element stops receiving callbacks while others continue. Its handler was evicted from the WeakMap — usually a second registration for the same element and configuration overwriting the first.
  • Every element receives every callback. A single global handler map is being consulted instead of the per-slot one.
  • Callbacks stop for a whole configuration after one component unmounts. The reference count went negative, or a caller called unobserve on the shared instance directly rather than using its release function.
  • Nothing fires after a route change. disposeAll ran and components did not re-register — the pool is correct, the re-registration is missing.
  • A handler exception kills the batch. Wrap the dispatch.

FAQ

How much does one observer instance per element actually cost?

Each instance allocates its own observation list and retains its own callback closure, and each closure typically captures the element and its surrounding component state. For a hundred elements that is a hundred closures kept alive for as long as the observers are, plus a hundred constructor calls at mount. The per-frame delivery cost is smaller than people expect; the setup and retention costs are larger.

What has to go into the pool key?

Everything that changes the observer's behaviour: the identity of the root element, the rootMargin string, and the threshold list after sorting and normalising it. Two configurations that differ in any of these are genuinely different observers and must not share an instance — a pool that keys only on rootMargin will silently give one caller another caller's thresholds.

Where does per-element state live if the callback is shared?

In a WeakMap keyed by the element. The shared callback receives entry.target and looks up that element's handler and state, which keeps the callback free of any element-specific closure. Using a WeakMap rather than a Map means a target removed from the document does not need an explicit delete to be collectable.

When should the pool drop an instance?

When its target count reaches zero. Reference-count on register and unregister, and disconnect and remove the instance on the transition to zero. Keeping an empty observer alive is cheap but not free, and an unbounded pool in a long-lived single-page application accumulates one instance per configuration ever used.

Is pooling worth it for a handful of elements?

For three or four elements with different configurations, no — the indirection costs more clarity than it saves cycles. Pooling pays off where many elements share one configuration, which is exactly the list, grid and feed case. Reach for it when the count is dynamic and potentially large, not as a default wrapper around every observer.


↑ Back to Performance Optimization & Memory Management for Observer APIs