IntersectionObserver and ResizeObserver
Stop Listening to Scroll Events
Before the Observer APIs, building features like lazy loading, infinite scroll, or responsive components meant attaching scroll and resize event listeners that fired dozens of times per second. You had to throttle or debounce them to avoid destroying performance, and the code was always a mess of getBoundingClientRect() calls and manual calculations.
The Observer APIs changed everything. Instead of constantly checking positions yourself, you tell the browser what to watch for, and it notifies you when something happens. It's more efficient (the browser optimizes internally), cleaner to write, and doesn't block the main thread.
Think of the old scroll listener approach as standing at a window constantly checking if your Amazon delivery truck is visible on the street. You check every second, all day, even when you know the package isn't coming until the afternoon. The Observer approach is like having a doorbell — you go about your day and it notifies you when something shows up. Much more efficient use of your time (and the browser's main thread).
IntersectionObserver
IntersectionObserver watches elements and tells you when they enter or leave the viewport (or any ancestor element you specify).
Basic Usage
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log('Element is visible:', entry.target);
} else {
console.log('Element left viewport:', entry.target);
}
});
});
// Start observing an element
const element = document.querySelector('.lazy-section');
observer.observe(element);
// Stop observing
observer.unobserve(element);
// Stop observing everything
observer.disconnect();
The Entry Object
Each entry in the callback gives you detailed information:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
entry.target; // the observed element
entry.isIntersecting; // is it in the viewport?
entry.intersectionRatio; // 0 to 1 — how much is visible
entry.boundingClientRect; // element's position
entry.rootBounds; // viewport bounds
entry.time; // timestamp
});
});
Options: Threshold, Root, and RootMargin
const observer = new IntersectionObserver(callback, {
root: null, // null = viewport, or specify an ancestor element
rootMargin: '0px', // margin around the root (CSS margin syntax)
threshold: 0, // ratio(s) at which to fire the callback
});
threshold — when to fire the callback:
// Fire when any pixel enters or leaves
{ threshold: 0 }
// Fire when 50% is visible
{ threshold: 0.5 }
// Fire at multiple points
{ threshold: [0, 0.25, 0.5, 0.75, 1] }
rootMargin — expand or shrink the detection area:
// Start loading images 200px BEFORE they enter the viewport
{ rootMargin: '200px 0px' }
// Detect only when element is 100px inside the viewport
{ rootMargin: '-100px 0px' }
Lazy Loading Images
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
observer.unobserve(img); // stop watching once loaded
});
}, {
rootMargin: '200px 0px' // start loading 200px before visible
});
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
Modern browsers support loading="lazy" on images and iframes natively: <img src="photo.jpg" loading="lazy" />. Use this for simple cases. Use IntersectionObserver when you need more control — custom thresholds, loading indicators, or non-image content.
Infinite Scroll
const sentinel = document.querySelector('.scroll-sentinel');
const scrollObserver = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting) return;
const newItems = await fetchNextPage();
renderItems(newItems);
// The sentinel stays at the bottom, so it triggers again
// when the user scrolls past the new items
});
scrollObserver.observe(sentinel);
The trick is a "sentinel" element at the bottom of the list. When it scrolls into view, you load more content. The sentinel naturally moves down as you add content above it, so it triggers again at the right time.
Scroll-Triggered Animations
const animateObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-in');
}
});
}, { threshold: 0.2 }); // trigger when 20% is visible
document.querySelectorAll('.animate-on-scroll').forEach(el => {
animateObserver.observe(el);
});
ResizeObserver
ResizeObserver fires when an element's size changes. This is different from the resize event on window, which only fires when the browser window resizes. ResizeObserver detects size changes caused by CSS, content changes, or layout shifts.
const observer = new ResizeObserver((entries) => {
entries.forEach(entry => {
const { width, height } = entry.contentRect;
console.log(`${entry.target.id}: ${width}x${height}`);
});
});
observer.observe(document.querySelector('.resizable-panel'));
Use Cases
Responsive components — adjust behavior based on the element's size, not the window's:
const chartObserver = new ResizeObserver((entries) => {
entries.forEach(entry => {
const { width } = entry.contentRect;
if (width < 400) {
entry.target.classList.add('compact');
entry.target.classList.remove('full');
} else {
entry.target.classList.add('full');
entry.target.classList.remove('compact');
}
});
});
chartObserver.observe(document.querySelector('.chart-container'));
This is what CSS container queries do declaratively, but ResizeObserver gives you JavaScript control for cases where you need to recalculate data, resize a canvas, or trigger non-CSS changes.
Be careful not to create infinite loops with ResizeObserver. If your callback changes the observed element's size (e.g., adjusting content based on width, which changes the height, which triggers the observer again), you can get an infinite resize loop. The browser detects this and throws an error: "ResizeObserver loop completed with undelivered notifications." Design your callback to avoid changing the observed element's dimensions.
MutationObserver
MutationObserver watches for changes to the DOM tree itself — added/removed nodes, attribute changes, and text content changes.
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
console.log('Children changed:', mutation.addedNodes, mutation.removedNodes);
}
if (mutation.type === 'attributes') {
console.log('Attribute changed:', mutation.attributeName);
}
});
});
observer.observe(document.querySelector('.dynamic-list'), {
childList: true, // watch for added/removed children
attributes: true, // watch for attribute changes
subtree: true, // watch all descendants, not just direct children
characterData: true, // watch for text content changes
});
// Stop watching
observer.disconnect();
When MutationObserver Is Useful
- Watching for third-party scripts modifying your DOM (analytics, ads, browser extensions)
- Building custom elements that react to attribute changes
- Monitoring dynamic content injection for accessibility announcements
- Detecting when a lazy-loaded component mounts
MutationObserver with subtree: true on a large, frequently-changing element can be expensive. Be specific about what you observe — only watch the elements and mutation types you actually need. Avoid observing document.body with all options enabled unless you have a good reason.
Observer Pattern Summary
| Observer | Watches For | Replaces |
|---|---|---|
IntersectionObserver | Elements entering/leaving a viewport or container | scroll + getBoundingClientRect |
ResizeObserver | Element size changes | window resize + manual size checks |
MutationObserver | DOM tree changes (nodes added/removed, attributes changed) | Polling + manual DOM comparison |
- 1Use IntersectionObserver for lazy loading, infinite scroll, and scroll-triggered animations
- 2Set rootMargin to trigger lazy loading BEFORE elements enter the viewport
- 3Always unobserve elements after they're handled (loaded images, triggered animations)
- 4Use ResizeObserver for responsive component behavior — not window resize events
- 5Use MutationObserver sparingly and be specific about what you watch
| What developers do | What they should do |
|---|---|
| Using scroll event listeners for lazy loading with getBoundingClientRect Scroll listeners fire on the main thread 60+ times per second and getBoundingClientRect forces layout. IntersectionObserver is optimized by the browser and runs off the main thread | Using IntersectionObserver with rootMargin for early detection |
| Forgetting to unobserve elements after they're handled Observing elements that no longer need watching wastes browser resources. For lazy loading, unobserve after the image loads. For one-time animations, unobserve after the class is added | Calling observer.unobserve(element) after the element is processed |
| Observing document.body with MutationObserver and subtree: true Watching the entire body for all mutation types generates massive amounts of callbacks for every DOM change anywhere on the page. Be surgical — observe only what you need | Observing the specific parent element where changes happen |