A pool is only as good as its key. Too coarse and two callers silently share an observer configured for one of them; too fine and the pool fragments into the per-element instances it existed to prevent.

Problem / Scenario Context

A pool is added to a codebase and the instance count barely falls. Inspection shows nine instances for what are, behaviourally, two configurations:

TypeScript
observe(a, fn, { rootMargin: '200px' });
observe(b, fn, { rootMargin: '200px 0px' });
observe(c, fn, { rootMargin: '200px 0 200px 0' });
observe(d, fn, { threshold: [0, 0.5, 1] });
observe(e, fn, { threshold: [1, 0.5, 0] });
observe(f, fn, { threshold: [0, 0.50, 1.0] });

Every one of those rootMargin strings resolves to the same computed margin, and every one of those threshold arrays produces the same crossing behaviour. Naively stringifying the options object produces six distinct keys and six instances.

The opposite failure is worse and quieter. A pool that keys only on rootMargin will hand a caller who asked for threshold: 0.5 an observer built with threshold: 0, and that caller's callback will fire at the wrong moments with no error anywhere.

Mechanics Explanation

Three option fields determine behaviour, and each needs its own normalisation.

rootMargin is a CSS-shorthand string. One, two, three or four components expand by the usual rules, and units may be px or %. Normalising means expanding to four explicit components with units.

threshold may be a number or an array, is deduplicated and sorted by the browser, and is subject to floating-point representation: 0.1 + 0.2 is not 0.3. Normalising means coercing to an array, rounding to a fixed precision, deduplicating and sorting.

root is an element reference, and two different elements can stringify identically. It needs an identity, not a serialisation — and that identity must not keep the element alive, which rules out a plain Map.

Six Option Objects, Two Behaviours, Two KeysSix caller configurations on the left, three expressing the same root margin and three expressing the same threshold set. Normalisation expands the margins to four components and sorts and rounds the thresholds, collapsing them to two distinct keys and therefore two pooled instances rather than six.'200px''200px 0px''200px 0 200px 0'expandviewport|200px 0px 200px 0px|0one key, one instance[0, 0.5, 1][1, 0.5, 0][0, 0.50, 1.0]sort + roundviewport|0px 0px 0px 0px|0,0.5,1one key, one instanceWithout normalisation this page allocates six observers where two are needed — and the pool looks like it is not working.

Comparison Table: Normalisation Rules

Field Naive key Correct key
rootMargin: '200px' '200px' '200px 200px 200px 200px'
rootMargin: '10px 20px' '10px 20px' '10px 20px 10px 20px'
rootMargin omitted undefined '0px 0px 0px 0px'
threshold: 0.5 0.5 '0.5'
threshold: [1, 0] '1,0' '0,1'
threshold: [0.1 + 0.2] '0.30000000000000004' '0.3'
root: someDiv '[object HTMLDivElement]' 'root#7' — a stable identity
root: null 'null' 'viewport'

Minimal Reproducible Example

TypeScript
// Fragments the pool: JSON key order and formatting both leak in
const key = JSON.stringify(opts);
// { threshold: 0, rootMargin: '0px' } and { rootMargin: '0px', threshold: 0 }
// produce different strings for identical behaviour

Production-Safe Solution

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}`;                 // identity without retaining the element
}

function marginKey(margin: string | undefined): string {
  const parts = (margin ?? '0px').trim().split(/\s+/);
  const withUnits = parts.map((p) => (/^-?\d*\.?\d+$/.test(p) ? `${p}px` : p));
  const [t, r = t, b = t, l = r] = withUnits;
  return `${t} ${r} ${b} ${l}`;
}

function thresholdKey(threshold: number | number[] | undefined): string {
  const list = Array.isArray(threshold) ? threshold : [threshold ?? 0];
  const cleaned = list
    .map((t) => Number(Math.min(1, Math.max(0, t)).toFixed(4)))   // clamp, then de-fuzz
    .filter((t, i, a) => a.indexOf(t) === i)                      // dedupe
    .sort((a, b) => a - b);
  return cleaned.join(',');
}

export function keyFor(opts: IntersectionObserverInit = {}): string {
  return `${rootKey(opts.root)}|${marginKey(opts.rootMargin)}|${thresholdKey(opts.threshold)}`;
}

The WeakMap for root identity is the detail most likely to be got wrong. A Map would work and would keep every root element that has ever been used alive for the life of the page — turning the pool into exactly the retention problem it is meant to help avoid.

Rounding to four decimal places is a judgement call rather than a rule. It is enough precision that no realistic threshold is conflated with another, and coarse enough that arithmetic like i / 20 in a loop produces stable keys across callers who generate their thresholds slightly differently.

Two smaller details are worth building in:

TypeScript
// A unitless zero is legal CSS and common in hand-written margins
marginKey('0');            // → '0px 0px 0px 0px'

// Percentages must NOT be normalised to px — they are a different behaviour
marginKey('10% 0px');      // → '10% 0px 10% 0px'

Too Coarse and Too Fine Both Fail, DifferentlyTwo failure modes either side of a correct key. A key that omits the threshold lets one caller receive an observer configured for another, producing callbacks at the wrong moments with no error. A key built by stringifying the options object fragments identical behaviours across many instances, so the pool provides no benefit. The correct key normalises each field independently.too coarse — key omits thresholdA caller asking for 0.5 receives an observer built with 0. Callbacks fire at the wrong moments, silently, forever.too fine — key is JSON.stringify(opts)Property order and formatting fragment identical behaviours. The pool allocates as many instances as callers.correct — each field normalised on its own termsrootKey(root) | marginKey(rootMargin) | thresholdKey(threshold)

Verification Steps

  • Unit-test the key function directly. Every row of the comparison table above is a test case, and they are cheap to write.
  • Assert the pool size after registering the six equivalent configurations from the reproduction: it should be two.
  • Assert two genuinely different configurations do not share: keyFor({ threshold: 0 }) must not equal keyFor({ threshold: 0.5 }).
  • Check root identity does not retain: register a root element, drop every reference, force GC and confirm it is collected in a heap snapshot.

The Key Function Is a Unit-Test TargetThree assertions for a pool key function: equivalent margins must produce one key, differing thresholds must produce two, and two distinct root elements must never collide. Together they prevent both fragmentation of the pool and silent cross-talk between callers.Three assertions that keep the pool honestkeyFor({rootMargin:"200px"}) === keyFor({rootMargin:"200px 0px 200px 0px"})keyFor({threshold:0}) !== keyFor({threshold:0.5})keyFor({root:divA}) !== keyFor({root:divB})The last two prevent silent cross-talk; the first prevents fragmentation.

Common Mistakes to Avoid

  • JSON.stringify(opts) as the key. Property order leaks in, and so does undefined versus omitted.
  • Keying on a subset of the options. The silent failure mode, and the hardest to diagnose because nothing errors.
  • A Map for root identity. Every root ever used is retained for the page's lifetime.
  • Normalising percentages to pixels. They are genuinely different behaviour and must produce different keys.
  • Forgetting to clamp thresholds. A value outside 0–1 throws at construction, so it is better rejected at the key boundary with a clear message.

FAQ

Why not just use JSON.stringify on the options object?

Because property order and formatting leak into the string. Two objects with the same fields written in a different order produce different keys, an omitted field differs from an explicitly undefined one, and none of that corresponds to any behavioural difference. The pool fragments and provides no benefit.

What is the risk of leaving threshold out of the key?

A caller that asked for a half threshold receives an observer built with zero, or vice versa. Its callback then fires at moments it did not ask for, with no error raised anywhere. This is the most damaging pooling bug precisely because nothing signals it.

Why does the root need a WeakMap rather than a Map?

Because a Map keyed by element keeps every root that has ever been used alive for the life of the page. That turns the pool into a retention problem — the exact class of leak that observer registries are usually built with WeakMap to avoid.

Should percentage root margins be converted to pixels?

No. A margin of ten per cent expands the root box relative to its size, which is genuinely different behaviour from a fixed pixel amount and must produce a different key. Normalise the shorthand to four components but leave the units alone.


↑ Back to Shared Observer Pooling