IntersectionObserver
Observe element visibility asynchronously — lazy images, infinite scroll, ad viewability, without scroll listener jank.
- javascript
- intersectionobserver
- performance
- lazy-load
Scroll listeners that call getBoundingClientRect on every frame jank. IntersectionObserver asks the browser to tell you when a target’s visibility against a root crosses thresholds — async, batched, and cheap enough for lazy-loading hundreds of images.
Minimal observer
const io = new IntersectionObserver(
(entries, observer) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
}
},
{
root: null, // viewport
rootMargin: '200px 0px', // preload before visible
threshold: 0.01,
},
);
document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));
Entry fields worth knowing
entry.isIntersecting; // boolean convenience
entry.intersectionRatio; // 0–1 visible fraction
entry.boundingClientRect;
entry.intersectionRect;
entry.rootBounds;
entry.target;
entry.time;
threshold can be a number or array:
new IntersectionObserver(cb, {
threshold: [0, 0.25, 0.5, 0.75, 1],
});
Callbacks fire when the ratio crosses those marks (not continuously).
root and rootMargin
const scroller = document.querySelector('.scrollport');
const io = new IntersectionObserver(cb, {
root: scroller, // default null = viewport
rootMargin: '0px 0px -40% 0px', // shrink root (sticky header math)
threshold: 0.5,
});
rootMargin grows/shrinks the root’s intersection box — like CSS margin syntax. Positive values expand (prefetch); negative shrink (must be more inside).
Infinite scroll
const sentinel = document.querySelector('#infinite-sentinel');
const io = new IntersectionObserver(async ([entry]) => {
if (!entry.isIntersecting || loading) return;
loading = true;
const page = await fetchNext();
appendRows(page);
loading = false;
});
io.observe(sentinel);
Disconnect or unobserve when no more pages exist.
Analytics / viewability
const seen = new WeakSet();
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.intersectionRatio >= 0.5 && !seen.has(e.target)) {
seen.add(e.target);
track('impression', e.target.dataset.id);
}
}
},
{ threshold: 0.5 },
);
Cleanup
io.unobserve(el);
io.disconnect(); // all targets
In React, observe in useEffect and disconnect on unmount.
Versus scroll handlers
| Scroll + rects | IntersectionObserver |
|---|---|
| Runs on main thread often | Browser optimizes |
| Easy to thrash layout | No forced layout in your callback ideally |
| Precise pixel control | Threshold-based |
You can still read rects inside the callback, but keep work light — schedule heavy jobs with requestIdleCallback / rAF.
Interview answer
“IntersectionObserver reports when a target crosses visibility thresholds relative to a root (usually the viewport). I use it for lazy images, infinite scroll sentinels, and impressions. rootMargin preloads early; threshold sets how much must be visible. It’s preferable to scroll listeners with getBoundingClientRect for performance.”
Related
Multiple targets and root bounds
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
e.target.classList.toggle('in-view', e.isIntersecting);
}
});
document.querySelectorAll('.reveal').forEach((el) => io.observe(el));
// one observer, many targets — cheaper than one observer per node
entry.rootBounds is null when the root isn’t a element (viewport) in some cases — don’t assume it’s always a DOMRect. For horizontal carousels, set root to the scroller element. Disconnect observers in tests between cases to avoid cross-test callbacks.
Zero-size targets
If the target has height: 0 or is display: none, it may never intersect. Sentinels for infinite scroll need non-zero size (even 1px) inside the scrollport. For elements inside transformed ancestors, intersection still works against the root; don’t mix with manual rect math unless necessary.
sentinel.style.height = '1px';
sentinel.setAttribute('aria-hidden', 'true');
Further reading
Related guides
- Debounce ImplementationImplement debounce in JavaScript — trailing vs leading, cancel/flush, TypeScript typing, and when to throttle instead.
- DocumentFragmentBuild subtrees off-DOM with DocumentFragment — one insert, fewer reflows, and how it differs from a wrapper div.
- Garbage Collection Mental ModelReachability, mark-and-sweep, retained closures and DOM — a practical GC model for frontend engineers debugging memory.
- Throttle ImplementationImplement throttle in JavaScript — leading vs trailing edges, cancel, comparison with debounce, React cleanup, and interview-ready code.
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.