"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.

Three Costs, Measured Separately, for 1000 ElementsThree paired bars comparing one thousand instances against one shared instance. Construction time is dramatically higher for separate instances. Retained memory is substantially higher. Per-frame delivery is only modestly higher, which is the opposite of what most people assume before measuring.1000 observed elements — indicative shape, not absolute numbersconstruction, at mount1000×retained memory1000 closures1 closure + a WeakMapper-frame delivery1000 callback invocations1 invocation, 1000 entriesThe bottom row is the one people expect to dominate. It is the smallest of the three.

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

TypeScript
// 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:

TypeScript
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:

TypeScript
// 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.

Alternate the Arms, or Measure the Drift InsteadTwo benchmark schedules. Running twenty iterations of one mode and then twenty of the other lets engine warm-up and thermal drift load entirely onto the second arm, biasing the result. Alternating the two modes spreads that drift evenly across both, so the difference that remains is the effect being measured.blocked — drift lands on the second arm20 × separate20 × sharedmachine slows over the run — arm two looks worsealternating — drift is sharedboth arms experience the same conditions — the remaining gap is the effect

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.

Report the Numbers in the CommitA commit message carrying the before and after figures for construction time, instance count and retained closures on a named device. Recording them means the next reviewer does not have to re-derive the justification, and a later regression has a real baseline.The measurement is only useful if it outlives the afternoonPool observers for the feed cardsMeasured on a mid-range phone, 200 cards, median of 20 alternating runs: mount: 41ms → 3ms instances: 200 → 1 closures: 200 → 1 (plus one WeakMap)

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.


↑ Back to Shared Observer Pooling