ResizeObserver & IntersectionObserver
Why Polling Dimensions Is Bad
Before we had these APIs, detecting element size changes was painful. You basically had to poll:
// ❌ Polling — runs every 100ms, wastes CPU, misses fast changes
setInterval(() => {
const width = element.offsetWidth;
if (width !== lastWidth) {
handleResize(width);
lastWidth = width;
}
}, 100);
This approach is terrible in every way:
- Wasted CPU: Runs even when nothing changes (99% of the time)
- Forced layout: Each
offsetWidthread forces synchronous layout if styles are dirty - Missed changes: A 100ms interval misses sub-100ms size changes. Reduce the interval and CPU waste increases.
And for visibility detection? The scroll listener approach was just as bad:
// ❌ Scroll listener — fires hundreds of times during a scroll
window.addEventListener('scroll', () => {
const rect = element.getBoundingClientRect(); // Forced layout
if (rect.top < window.innerHeight) {
loadContent();
}
});
getBoundingClientRect forces layout on every scroll event. At 60fps scrolling, that is 60 forced layouts per second on the main thread.
Observers invert the control flow. Instead of you asking the browser "has anything changed?" on a loop, the browser tells you when something changes. The browser already tracks layout and visibility internally — observers let you hook into that existing work. Zero overhead when nothing changes, precise notifications when something does.
ResizeObserver
Basic Usage
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentBoxSize[0];
console.log(`Element resized: ${width}×${height}`);
}
});
observer.observe(element);
// Stop observing
observer.unobserve(element);
// Stop all observations
observer.disconnect();
Entry Object
Each ResizeObserverEntry provides three box measurements:
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
// Content box: excludes padding and border
const contentWidth = entry.contentBoxSize[0].inlineSize;
const contentHeight = entry.contentBoxSize[0].blockSize;
// Border box: includes padding and border
const borderWidth = entry.borderBoxSize[0].inlineSize;
const borderHeight = entry.borderBoxSize[0].blockSize;
// Device pixel content box: physical pixels (for canvas rendering)
const pixelWidth = entry.devicePixelContentBoxSize?.[0].inlineSize;
const pixelHeight = entry.devicePixelContentBoxSize?.[0].blockSize;
// Legacy (avoid — does not handle writing modes correctly)
const rect = entry.contentRect; // { x, y, width, height }
}
});
Use Cases
Responsive Components (Container Queries Alternative)
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.borderBoxSize[0].inlineSize;
if (width < 400) {
entry.target.classList.add('compact');
entry.target.classList.remove('wide');
} else {
entry.target.classList.add('wide');
entry.target.classList.remove('compact');
}
}
});
document.querySelectorAll('.card').forEach(card => observer.observe(card));
Canvas Pixel-Perfect Rendering
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
// Use device pixels for sharp canvas rendering
const dpr = window.devicePixelRatio;
const width = entry.devicePixelContentBoxSize?.[0].inlineSize
?? entry.contentBoxSize[0].inlineSize * dpr;
const height = entry.devicePixelContentBoxSize?.[0].blockSize
?? entry.contentBoxSize[0].blockSize * dpr;
canvas.width = width;
canvas.height = height;
redrawCanvas();
}
});
observer.observe(canvas);
IntersectionObserver
Basic Usage
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
console.log('Element is visible');
loadImage(entry.target);
observer.unobserve(entry.target); // Stop observing after load
}
}
});
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img);
});
Options: threshold and rootMargin
const observer = new IntersectionObserver(callback, {
// root: null = viewport (default). Can be any scrollable ancestor.
root: null,
// rootMargin: extends or shrinks the intersection area
// Positive = trigger before element enters viewport
rootMargin: '200px 0px', // Start loading 200px before visible
// threshold: at what visibility ratio to trigger
threshold: 0, // Trigger when any pixel is visible (default)
// threshold: 0.5, // Trigger when 50% visible
// threshold: 1.0, // Trigger when 100% visible
// threshold: [0, 0.25, 0.5, 0.75, 1], // Trigger at multiple ratios
});
Use Cases
Lazy Loading Images
const lazyObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
lazyObserver.unobserve(img);
}
});
}, { rootMargin: '300px 0px' });
Infinite Scroll
const sentinel = document.querySelector('.load-more-sentinel');
const scrollObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadNextPage();
}
}, { rootMargin: '500px 0px' });
scrollObserver.observe(sentinel);
Visibility Tracking (Analytics)
const trackingObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
trackImpression(entry.target.dataset.itemId);
trackingObserver.unobserve(entry.target);
}
});
}, { threshold: 0.5 });
Scroll-Triggered Animations
const animObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
entry.target.classList.toggle('animate-in', entry.isIntersecting);
});
}, { threshold: 0.15 });
Performance Characteristics
You might be wondering: are these observers actually efficient, or are they just a nicer API over the same expensive work? The answer is they are genuinely faster:
- Batch delivery: Callbacks fire once per frame with all changed entries, not once per element
- Off main thread (partially): IntersectionObserver's intersection calculations happen during compositing, not in JavaScript
- No forced layout: Observers use the browser's internal layout data — no
getBoundingClientRectcalls
ResizeObserver callbacks fire after layout but before paint (between layout and paint in the rendering pipeline). IntersectionObserver callbacks are queued during the rendering steps and delivered as tasks — not microtasks — after the intersection data is computed. This means ResizeObserver mutations can be reflected in the same frame (no flash), while IntersectionObserver reactions typically appear in the next frame.
Avoiding Observation Loops
This is the one gotcha that bites everyone at least once. ResizeObserver has a specific pitfall: if your callback changes the observed element's size, you create an infinite loop:
// ❌ INFINITE LOOP — callback changes size, triggering another callback
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
entry.target.style.height = (entry.contentBoxSize[0].blockSize + 10) + 'px';
// This size change triggers the observer again → infinite loop
}
});
The browser is smart enough to not actually freeze. It prevents true infinite loops with a depth limit. After the first observation, if the callback causes new size changes, the browser fires the observer again. This repeats up to a depth limit (typically 4-8). After that, it reports an error via ResizeObserver loop completed with undelivered notifications and stops.
// ✅ SAFE — change a different element, not the observed one
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.borderBoxSize[0].inlineSize;
otherElement.style.width = width + 'px'; // Different element — no loop
}
});
React Integration Patterns
function useResizeObserver<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
setSize({
width: entry.borderBoxSize[0].inlineSize,
height: entry.borderBoxSize[0].blockSize,
});
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return { ref, ...size };
}
// Usage
function ResponsiveCard() {
const { ref, width } = useResizeObserver<HTMLDivElement>();
return (
<div ref={ref} className={width < 400 ? 'compact' : 'wide'}>
{/* content */}
</div>
);
}
- 1Never poll dimensions with setInterval — use ResizeObserver. Never use scroll listeners for visibility — use IntersectionObserver.
- 2ResizeObserver provides contentBoxSize, borderBoxSize, and devicePixelContentBoxSize. Use logical properties (inlineSize/blockSize) for writing mode correctness.
- 3IntersectionObserver's rootMargin creates a buffer zone — use 200-500px to start loading/rendering before elements are visible.
- 4Callbacks are batched per frame. One callback fires with all changed entries, not one callback per element.
- 5ResizeObserver callbacks fire between layout and paint (same-frame mutations possible). IntersectionObserver callbacks fire after rendering (next-frame reactions).
- 6Never change an observed element's size inside a ResizeObserver callback — it creates a feedback loop that hits the browser's depth limit.
- 7Both observers use internal browser data, avoiding forced layout. They are fundamentally cheaper than manual dimension reading.
Q: Implement lazy-loading images that start loading 300px before they enter the viewport, track when they are 50% visible for analytics, and clean up properly.
A strong answer uses two IntersectionObservers: one with rootMargin: '300px' and threshold: 0 for loading (unobserve after load), another with threshold: 0.5 for visibility tracking (unobserve after tracking). Mentions that combining both thresholds in one observer is possible but less clear. Discusses cleanup: disconnect observers on component unmount, handle dynamically added images. Bonus: mention native lazy loading (loading="lazy") as a simpler alternative when the default browser heuristics are acceptable, and fetchpriority for above-fold images.