IntersectionObserver Batched With rootMargin
On a feed with 200 cards, creating one IntersectionObserver per card pushed our scroll frame to 14ms. This is the single shared observer with `rootMargin` prefetch and a batched callback that brought it back to 4ms.
By @emmakim
April 9, 2026
·
Updated August 12, 2026
501 views
9
Rate
// makeBatchedVisibilityTracker: a single IntersectionObserver shared by every
// element you want to track. The callback fires with arrays of newly-visible
// and newly-hidden nodes, so a feed can mark 50 cards 'in view' in one render.
function makeBatchedVisibilityTracker({ rootMargin = '0px', threshold = 0 } = {}) {
const visible = new Set();
const listeners = new Set();
const observer = new IntersectionObserver(
(entries) => {
const newlyIn = [];
const newlyOut = [];
for (const entry of entries) {
const target = entry.target;
const wasVisible = visible.has(target);
if (entry.isIntersecting && !wasVisible) {
visible.add(target);
newlyIn.push(target);
} else if (!entry.isIntersecting && wasVisible) {
visible.delete(target);
newlyOut.push(target);
}
}
if (newlyIn.length || newlyOut.length) {
for (const fn of listeners) fn({ newlyIn, newlyOut });
}
},
{ rootMargin, threshold },
);
return {
track(el) { observer.observe(el); },
untrack(el) { observer.unobserve(el); visible.delete(el); },
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
destroy() { observer.disconnect(); listeners.clear(); visible.clear(); },
};
}
// Drive it with a stub IntersectionObserver since the playground has no real DOM.
let capturedCallback = null;
class StubIO {
constructor(cb) { capturedCallback = cb; }
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.IntersectionObserver = StubIO;
const tracker = makeBatchedVisibilityTracker();
const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
for (const n of nodes) tracker.track(n);
tracker.onChange((evt) => {
console.log('newly in :', evt.newlyIn.map((n) => n.id));
console.log('newly out:', evt.newlyOut.map((n) => n.id));
});
// Simulate the browser delivering a batch.
capturedCallback([
{ target: nodes[0], isIntersecting: true },
{ target: nodes[1], isIntersecting: true },
{ target: nodes[2], isIntersecting: false },
]);
capturedCallback([
{ target: nodes[0], isIntersecting: false },
{ target: nodes[2], isIntersecting: true },
]);Sharing one observer is the optimization the spec already enables but most code does not use. The browser batches the callback for you (entries arrive as an array per tick), so handling them in groups instead of per-element keeps the scroll frame cheap. The Set of currently-visible elements is what makes the diff possible: without it, the callback fires once on initial observe with isIntersecting: false for every element, and naive code marks them all hidden. The onChange listener pattern means a feed component, an analytics module, and a lazy-image loader can all subscribe to the same observer.
// The feed loads images when they enter the viewport, but the user perceives
// pop-in if we wait for true visibility. rootMargin lets us 'expand' the
// observation rectangle by 200px below, so we preload while the card is still
// 200px below the fold.
let capturedOptions = null;
let capturedCallback = null;
class StubIO {
constructor(cb, options) { capturedCallback = cb; capturedOptions = options; }
observe() {} unobserve() {} disconnect() {}
}
globalThis.IntersectionObserver = StubIO;
function makeLazyLoader({ rootMargin = '200px 0px 200px 0px' } = {}) {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const el = entry.target;
if (el.dataset.src && !el.src) {
el.src = el.dataset.src;
observer.unobserve(el); // we only need one shot
}
}
}
},
{ rootMargin, threshold: 0 },
);
return {
observe(el) { observer.observe(el); },
destroy() { observer.disconnect(); },
};
}
const loader = makeLazyLoader();
console.log('rootMargin in use:', capturedOptions.rootMargin);
const img = { dataset: { src: 'photo.jpg' }, src: '' };
loader.observe(img);
capturedCallback([{ target: img, isIntersecting: true }]);
console.log('image src after intersection:', img.src);rootMargin accepts CSS-style top/right/bottom/left values; positive values expand the rectangle, negative values shrink it. For lazy-loading I use 200px 0px 200px 0px so we start fetching when the image is roughly one viewport above or below visible, which feels instant on a normal scroll. For analytics impressions I do the opposite: a negative rootMargin of -25% means the impression only counts when the card is fully past the top quarter of the viewport, which suppresses the noisy half-second hover at the fold. Same observer API, two completely different use cases.
// thresholds let you fire callbacks at intermediate visibility ratios. We
// use this to crossfade between 'incoming' and 'outgoing' states for hero
// sections in a long-form article. threshold: [0, 0.25, 0.5, 0.75, 1] gives
// you five callbacks per scroll past, which is enough resolution for an opacity
// curve without becoming jittery.
let capturedCallback = null;
let capturedOptions = null;
class StubIO {
constructor(cb, options) { capturedCallback = cb; capturedOptions = options; }
observe() {} unobserve() {} disconnect() {}
}
globalThis.IntersectionObserver = StubIO;
function smoothlyTrack(target, onRatio) {
const observer = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.target === target) onRatio(e.intersectionRatio);
}
},
{ threshold: [0, 0.25, 0.5, 0.75, 1] },
);
observer.observe(target);
return () => observer.disconnect();
}
const hero = { id: 'hero' };
const opacityFromRatio = (r) => Math.round(r * 100) + '%';
smoothlyTrack(hero, (r) => console.log('opacity ->', opacityFromRatio(r)));
console.log('thresholds in use:', capturedOptions.threshold);
for (const r of [0, 0.25, 0.5, 0.75, 1]) {
capturedCallback([{ target: hero, intersectionRatio: r }]);
}An array of thresholds tells the observer to fire whenever intersectionRatio crosses one of the listed values. Five steps is the sweet spot I have landed on for cinematic scroll effects: 21 thresholds (every 5%) is overkill and starts to lag on Safari, while a single threshold means abrupt jumps. The browser automatically sorts and de-duplicates the array, so I do not bother. For a parallax effect I sometimes pair this with rootMargin to extend the callback range past the actual viewport, which keeps the animation playing as the section exits.
