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.
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
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
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
// 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.
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.
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, orrootMarginvariants 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
unobserveon the shared instance directly rather than using its release function. - Nothing fires after a route change.
disposeAllran 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.
Related
- One Observer vs Many: Benchmarking the Difference — the numbers behind the recommendation
- Keying an Observer Pool by Threshold and rootMargin — normalisation traps that fragment a pool
- Disconnecting Pooled Observers on Route Change — the single-page application teardown
- Observer Pool Memory Profile in Single-Page Apps — what a heap snapshot shows before and after
- WeakMap Observer Registries — the registry structure the pool depends on
↑ Back to Performance Optimization & Memory Management for Observer APIs