"Share one observer" is repeated often enough to have become folklore, which means it is applied where it does not help and skipped where it does. This page measures the three costs it addresses so the decision can be made from data.
Problem / Scenario Context
A feed renders cards in pages of fifty. Each card component creates its own IntersectionObserver for lazy loading. A reviewer flags it, the author asks how much it actually costs, and nobody has a number — so the change is either made on faith or not made at all.
The honest answer is that it depends on three separate quantities that behave differently, and only one of them is what most people assume.
Mechanics Explanation
Construction cost. Each new IntersectionObserver() allocates an internal observation list and registers the instance with the engine's intersection machinery. This is not free, and it is paid synchronously during mount — the worst possible moment, competing with the render that put the cards on screen.
Retained memory. Each instance holds its callback, and each callback closure captures whatever was in scope where it was defined: in a component, that is typically the element plus some component state. A thousand instances is a thousand closures, each anchoring a small object graph, for as long as the observers live.
Per-frame delivery cost. This is the one people expect to dominate and it usually does not. The engine computes intersections during the layout pass it was running anyway; what differs is how the results are dispatched — a thousand separate callback invocations versus one invocation with a thousand entries. Function-call overhead is real but small compared with the other two.
The practical consequence is that pooling is a mount-time and memory optimisation far more than a scroll-time one, and benchmarks that only measure scrolling will under-report its value.
Comparison Table: What to Measure and How
| Quantity | How to measure | What a bad result looks like |
|---|---|---|
| Construction | performance.mark around the mount loop |
a visible pause when a page of cards renders |
| Retained memory | heap snapshot, filter for the observer class | instance count tracking element count |
| Delivery | performance.measure inside the callback |
callback time rising with element count |
| Total mount time | event entries for the interaction that mounted them |
interaction latency over 200 ms |
Minimal Reproducible Example
// The shape being measured
function mountSeparate(cards: Element[]): IntersectionObserver[] {
return cards.map((card) => {
const io = new IntersectionObserver((entries) => onVisible(entries, card), OPTS);
io.observe(card);
return io; // one instance, one closure, per card
});
}
function mountShared(cards: Element[]): IntersectionObserver {
const io = new IntersectionObserver((entries) => {
for (const e of entries) onVisible([e], e.target); // state via entry.target
}, OPTS);
cards.forEach((c) => io.observe(c));
return io;
}
Production-Safe Solution
The benchmark itself is worth writing once and keeping, because it answers the question again whenever someone asks:
const OPTS: IntersectionObserverInit = { rootMargin: '200px 0px', threshold: 0 };
function benchConstruction(cards: Element[], mode: 'separate' | 'shared'): number {
performance.mark('mount:start');
const handle = mode === 'separate' ? mountSeparate(cards) : mountShared(cards);
performance.mark('mount:end');
const m = performance.measure('mount', 'mount:start', 'mount:end');
Array.isArray(handle) ? handle.forEach((o) => o.disconnect()) : handle.disconnect();
performance.clearMarks(); performance.clearMeasures();
return m.duration;
}
// Run both, alternating, to spread out engine warm-up effects
const results = { separate: [] as number[], shared: [] as number[] };
for (let i = 0; i < 20; i++) {
results.separate.push(benchConstruction(cards, 'separate'));
results.shared.push(benchConstruction(cards, 'shared'));
}
const median = (xs: number[]) => xs.sort((a, b) => a - b)[Math.floor(xs.length / 2)];
console.table({ separate: median(results.separate), shared: median(results.shared) });
Two methodology points matter more than the code.
Alternate the two modes rather than running all of one then all of the other. Engine warm-up, garbage collection and thermal effects all drift over a run, and alternating spreads that drift across both arms instead of loading it onto whichever went second.
Report the median, not the mean. A single garbage collection pause during one iteration will move a mean by more than the effect you are measuring.
For the memory arm, the measurement is a heap snapshot rather than a timer:
// 1. mount, 2. snapshot, 3. filter the class list for IntersectionObserver
// separate: instance count == card count
// shared: instance count == 1, plus one WeakMap
And for delivery, instrument the callback and scroll, using the long-task correlation technique to separate your callback's time from everything else in the frame.
Verification Steps
- Check the instance count in a heap snapshot rather than trusting the code to have done what you think.
- Run the construction benchmark on a mid-range phone, not a development machine; the ratio is far larger where CPU is scarce.
- Confirm the delivery arm is measuring delivery by checking the callback measures do not include your own DOM writes.
- Re-run after the change and record both numbers in the commit message, so the next reviewer does not have to ask.
Common Mistakes to Avoid
- Benchmarking only scroll performance. It is the smallest of the three costs and will under-sell the change.
- Measuring with ten elements. The effect is roughly linear in element count; ten of anything is free.
- Comparing means across a long run. One GC pause dominates the result.
- Pooling three observers because the benchmark said pooling is faster. The relative difference is meaningless when the absolute cost is a fraction of a millisecond.
FAQ
Which cost does pooling actually reduce most?
Construction time and retained memory, by a wide margin. Per-frame delivery — the cost most people assume dominates — differs far less, because the engine computes intersections during a layout pass it was performing anyway and only the dispatch differs.
How many elements before pooling is worth it?
There is no universal threshold, but the effect scales roughly with element count, so a handful of observers is never worth the indirection and a page that mounts dozens or hundreds at once almost always is. Measure construction time on a mid-range phone rather than a development machine, where the ratio is much larger.
Why alternate the two arms of the benchmark?
Because engine warm-up, garbage collection and thermal throttling all drift over the course of a run. Running one mode fully and then the other loads that drift onto whichever went second, which can easily be larger than the effect being measured.
Does one shared callback with a thousand entries block the main thread longer?
Only if the per-entry work is expensive — which is a property of your callback, not of pooling. The dispatch itself is cheaper as one invocation than as a thousand. If the loop body is slow, the fix is the loop body, and pooling makes it easier to batch that work across entries.
Related
- Shared Observer Pooling — the pool these numbers justify
- Observer Pool Memory Profile in Single-Page Apps — the memory arm, in a heap snapshot
- Optimizing IntersectionObserver for 1000 List Items — the scenario these measurements come from
↑ Back to Shared Observer Pooling