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.

JavaScript
Frontend
3 snippets
intersection-observer
js-dom
performance
frontend
emmakim

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.