You've probably written this before:
window.addEventListener('scroll', () => {
const rect = element.getBoundingClientRect();
if (rect.top < window.innerHeight) {
// do something
}
});It works. But it fires on every single scroll event — that's 60+ times per second on most devices. Add getBoundingClientRect() triggering layout reflows each time, and you've got a recipe for janky scrolling and poor Lighthouse scores.
The Intersection Observer API was built to solve exactly this problem. It lets you watch elements and get notified only when they actually cross a visibility threshold — no manual scroll handling, no layout thrashing, no performance headaches.
The API
At its core, it's simple:
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log('Element is visible!', entry.target);
}
});
}, options);
observer.observe(targetElement);You create an observer with a callback and some options, then tell it which elements to watch. When those elements enter or leave the viewport (or a custom root), your callback fires.
The Options Object
const options = {
root: null, // viewport by default, or a specific element
rootMargin: '0px', // grow/shrink the root's bounding box
threshold: 0 // when to trigger (0 = any pixel visible, 1 = 100% visible)
};root — The element used as the viewport for visibility checking. null means the browser viewport. You can pass any element to observe visibility within a scrollable container.
rootMargin — Margin around the root. Think of it like CSS margins — '100px 0px' means "trigger 100px before the element actually enters the viewport." This is the key to pre-loading content just before it scrolls into view.
threshold — Can be a single number or an array. 0 triggers the moment any part is visible. 1 triggers only when 100% is visible. [0, 0.25, 0.5, 0.75, 1] triggers at each quarter.
// Trigger at 25%, 50%, 75%, and 100% visibility
const observer = new IntersectionObserver(callback, {
threshold: [0.25, 0.5, 0.75, 1.0]
});The Entry Object
Each entry in the callback gives you rich data:
{
isIntersecting: true, // currently visible?
intersectionRatio: 0.65, // 65% visible
target: element, // the DOM element
boundingClientRect: {}, // element's bounds
intersectionRect: {}, // visible portion's bounds
rootBounds: {}, // root's bounds
time: 1234.5 // timestamp when intersection changed
}Practical Use #1: Lazy Loading Images
This is the most common use case and the one with the biggest performance impact. Instead of loading all images on page load, load them only when they're about to scroll into view.
<img data-src="photo.jpg" alt="A photo" class="lazy" />const imageObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
imageObserver.unobserve(img); // stop watching — we're done
}
});
},
{ rootMargin: '200px' } // load 200px before visible
);
document.querySelectorAll('img.lazy').forEach((img) => {
imageObserver.observe(img);
});The rootMargin: '200px' trick is important — it starts loading images slightly before the user scrolls to them, so by the time they arrive, the image is already there. No blank space, no loading spinners.
Don't forget unobserve() — once the image is loaded, there's no reason to keep watching it. This keeps the observer lightweight.
A Quick Gotcha: SEO and No-JS
Search engines generally execute JavaScript, but for bulletproof SEO, add a <noscript> fallback:
<img data-src="photo.jpg" alt="A photo" class="lazy" />
<noscript>
<img src="photo.jpg" alt="A photo" />
</noscript>Practical Use #2: Infinite Scroll
Pagination is fine, but infinite scroll keeps users engaged. Here's a clean pattern:
const sentinel = document.getElementById('sentinel'); // empty div at the bottom
const scrollObserver = new IntersectionObserver(
async (entries) => {
if (entries[0].isIntersecting) {
const posts = await fetchMorePosts();
renderPosts(posts);
// If there's a next page, the sentinel stays — observer keeps watching
// If we're done, remove the sentinel
if (!posts.hasMore) {
scrollObserver.unobserve(sentinel);
sentinel.remove();
}
}
},
{ rootMargin: '400px' }
);
scrollObserver.observe(sentinel);The "sentinel" pattern — an empty div at the bottom of your content list — is cleaner than observing the last element, because you don't need to re-observe when new elements are added. The sentinel just sits at the bottom.
Why this beats scroll listeners: The browser handles the scroll math internally. Your callback only fires when the sentinel enters the trigger zone. No debounce needed, no requestAnimationFrame hacks.
Practical Use #3: Scroll-Triggered Animations
Reveal elements as they scroll into view — a staple of modern landing pages:
const animateObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-in');
animateObserver.unobserve(entry.target);
}
});
},
{ threshold: 0.15 }
);
document.querySelectorAll('.reveal').forEach((el) => {
animateObserver.observe(el);
});.reveal {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.reveal.animate-in {
opacity: 1;
transform: translateY(0);
}This approach is better than scroll-based animation libraries because:
- No JavaScript runs on every frame — the browser handles intersection detection natively
- CSS transitions handle the animation — GPU-accelerated, no JS animation loop
- One-shot —
unobserveafter triggering, zero ongoing cost
Staggered Animations
Want children to animate one after another?
const staggerObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const children = entry.target.querySelectorAll('.stagger-child');
children.forEach((child, i) => {
child.style.transitionDelay = `${i * 100}ms`;
child.classList.add('animate-in');
});
staggerObserver.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);Performance and Core Web Vitals
Here's why Intersection Observer matters for your Lighthouse score:
Largest Contentful Paint (LCP)
Lazy loading your hero image? Don't. The LCP image should load immediately — lazy load everything below the fold only. A common mistake:
// ❌ Bad — lazy loads the hero image, killing LCP
document.querySelectorAll('img').forEach(img => observer.observe(img));
// ✅ Good — skip the first N images or use loading="eager" on the hero
document.querySelectorAll('img.lazy').forEach(img => observer.observe(img));Or better yet, use the native loading attribute for images and let the browser handle it:
<img src="hero.jpg" loading="eager" alt="Hero" /> <!-- LCP image -->
<img src="photo2.jpg" loading="lazy" alt="Gallery" /> <!-- below fold -->Cumulative Layout Shift (CLS)
Lazy-loaded images cause layout shifts if you don't reserve space. Always set dimensions:
<!-- ❌ Causes CLS — browser doesn't know the size until loaded -->
<img data-src="photo.jpg" class="lazy" alt="Photo" />
<!-- ✅ No CLS — space is reserved -->
<img data-src="photo.jpg" class="lazy" alt="Photo" width="800" height="600" />Or use aspect-ratio in CSS:
.lazy-image {
aspect-ratio: 16 / 9;
width: 100%;
background: #f0f0f0; /* placeholder color */
}First Input Delay (FID) / Interaction to Next Paint (INP)
Intersection Observer callbacks are microtasks — they don't block the main thread like scroll event handlers do. This directly improves INP because:
- No
getBoundingClientRect()forced layouts - No JavaScript running 60 times per second
- The browser optimizes intersection checks internally (often off main thread)
Observing Multiple Elements Efficiently
One observer can watch many elements — this is more efficient than creating one observer per element:
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
handleVisible(entry.target);
observer.unobserve(entry.target);
}
});
}, { rootMargin: '100px' });
// Observe 500 images with ONE observer
document.querySelectorAll('img.lazy').forEach(img => observer.observe(img));Disconnecting and Cleanup
When you're done with an observer entirely:
observer.disconnect(); // stops observing ALL elementsOr to stop watching a specific element:
observer.unobserve(element);Always clean up — observers hold references to DOM elements, preventing garbage collection.
Browser Support
Intersection Observer is supported in all modern browsers (95%+ globally). For IE11 (if you still need it), there's a lightweight polyfill from the W3C.
Quick Reference
| What you need | Observer config |
|---|---|
| Lazy load images | { rootMargin: '200px' } + unobserve after load |
| Infinite scroll | Sentinel div + { rootMargin: '400px' } |
| Scroll animations | { threshold: 0.15 } + CSS transitions |
| Video autoplay in view | { threshold: 0.5 } — play when 50% visible |
| Sticky header detection | { rootMargin: '-1px 0px 0px 0px', threshold: [1] } on a sentinel at top |
Summary
- Stop using scroll listeners for visibility detection — Intersection Observer does it better, cheaper, and without layout thrashing
- Use
rootMarginto pre-load content before it scrolls into view (lazy loading) or delay triggers until elements are deep in the viewport - Always
unobservewhen you're done with an element — don't leak memory - Reserve image dimensions to prevent CLS when lazy loading
- Don't lazy load your LCP image — it hurts your Core Web Vitals score
- One observer, many elements — it's designed for it and more performant than multiple observers