Here is a pattern I still see in real codebases:
window.addEventListener('scroll', () => {
const rect = element.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom >= 0) {
loadImage(element);
}
});
It works. It's also firing on every pixel of scroll movement — potentially 60 callbacks per second on a smooth display — and each one calls getBoundingClientRect(), which forces a synchronous layout recalculation. You probably added throttling or requestAnimationFrame wrapping to soften the blow. You're still doing layout work on the main thread to answer a question the browser already knows the answer to.
IntersectionObserver has been baseline since 2019. It's still underused.
What it does
IntersectionObserver watches one or more elements and fires a callback only when their visibility status changes — entering the viewport, leaving it, or crossing a fraction threshold you specify. The geometry calculations happen off the main thread; you get called when something meaningful happens.
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log(entry.target, 'entered the viewport');
}
});
});
observer.observe(document.querySelector('.card'));
entries is an array of IntersectionObserverEntry objects — one per observed element that changed state in this tick. entry.isIntersecting is true when the element entered, false when it left.
Lazy loading an image
The classic use case. No scroll event, no position math:
const lazyImages = document.querySelectorAll('img[data-src]');
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const img = entry.target;
img.src = img.dataset.src;
imageObserver.unobserve(img); // done watching this one
});
});
lazyImages.forEach(img => imageObserver.observe(img));
The observer fires once when each image enters the viewport. After setting src, unobserve drops the element from the watch list. No cleanup loop, no residual listener, no memory leak.
threshold and rootMargin
Two options control exactly when the callback fires.
threshold is a number between 0 and 1 — the fraction of the element that must be visible. The default is 0, which fires the moment a single pixel enters the viewport. threshold: 1 waits until the element is fully visible. Pass an array to fire at multiple milestones:
const observer = new IntersectionObserver(callback, {
threshold: [0, 0.25, 0.5, 0.75, 1],
});
entry.intersectionRatio tells you the actual fraction at the moment of the callback — useful for video autoplay, progressive reveal animations, or impression-depth analytics.
rootMargin works like a CSS margin around the viewport boundary, shifting where "visible" begins:
const observer = new IntersectionObserver(callback, {
rootMargin: '0px 0px 300px 0px', // fire 300px before entering from the bottom
});
This is the correct tool for preloading. Trigger the network request 300px before the element reaches the viewport, so it's ready when the user gets there — without any manual distance math.
Analytics impression tracking
Knowing whether a user actually saw a piece of content, versus just scrolling past it, is a common product requirement. With a scroll listener you're sampling at intervals and accepting inaccuracy. With IntersectionObserver:
const impressionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
analytics.track('impression', {
id: entry.target.dataset.id,
ratio: entry.intersectionRatio,
});
impressionObserver.unobserve(entry.target);
});
}, { threshold: 0.5 }); // at least half the element must be visible
document.querySelectorAll('[data-track]').forEach(el => impressionObserver.observe(el));
The threshold: 0.5 option means a fast scroll past an element doesn't count — the user had to slow down enough for half of it to enter view. One observer, zero scroll event listeners, accurate data.
Cleanup in components
In React, Vue, or any component that mounts and unmounts, disconnect the observer on teardown:
useEffect(() => {
const observer = new IntersectionObserver(callback);
observer.observe(ref.current);
return () => observer.disconnect(); // stops watching all elements
}, []);
disconnect() removes all observed elements at once. If you have a single long-lived observer watching multiple elements and only want to stop tracking one, unobserve(element) does that without affecting the rest.
The takeaway
IntersectionObserver answers the question "is this element visible?" without running on every scroll tick. The main thread is not involved — the browser handles the geometry internally and calls your callback only when the answer changes.
Search your codebase for scroll event listeners that contain getBoundingClientRect. Every one of them is a candidate for replacement. The handler runs less often, the layout thrash disappears, and the intent is clearer in the code: you're watching for visibility, and the observer is the right name for that.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 📸 Instagram — frontend best practices, daily: @bestpractice___
- 💼 LinkedIn — linkedin.com/in/parsa-jiravand
- ✉️ Email (work & contract inquiries): bestpractice2026@gmail.com
Top comments (0)