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:
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.
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
// 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
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:
// 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'
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 equalkeyFor({ 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.
Common Mistakes to Avoid
JSON.stringify(opts)as the key. Property order leaks in, and so doesundefinedversus omitted.- Keying on a subset of the options. The silent failure mode, and the hardest to diagnose because nothing errors.
- A
Mapfor 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.
Related
- Shared Observer Pooling — the pool this key feeds
- Disconnecting Pooled Observers on Route Change — what happens to these instances when the page changes
- WeakMap Observer Registries — why root identity uses a WeakMap
↑ Back to Shared Observer Pooling