Browser Observer APIs
The browser provides four observer APIs that replace expensive polling and manual checking with efficient, callback-based observation. Senior engineers use these instead of scroll handlers, timers, and manual DOM scanning.
The Observer Pattern in the Browser
All four observers share the same pattern:
const observer = new SomeObserver(callback, options);
observer.observe(target);
// ... later
observer.unobserve(target);
observer.disconnect();| Observer | Watches | Replaces |
|---|---|---|
| IntersectionObserver | Element visibility in viewport | Scroll handlers + getBoundingClientRect |
| MutationObserver | DOM tree changes | Polling, deprecated Mutation Events |
| ResizeObserver | Element size changes | Window resize handler + polling |
| PerformanceObserver | Performance entries | Manual performance.getEntries() polling |
IntersectionObserver
Detects when an element enters or exits the viewport (or a parent container). This is the go-to API for lazy loading, infinite scroll, analytics tracking, and scroll-triggered animations.
Basic Usage
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log(`${entry.target.id} is visible`);
console.log(`Visible ratio: ${entry.intersectionRatio}`);
}
});
},
{
root: null, // null = viewport; or a scrollable parent
rootMargin: '0px', // Margin around root (can trigger before visible)
threshold: [0, 0.25, 0.5, 0.75, 1.0] // Fire at these visibility ratios
}
);
observer.observe(document.getElementById('target'));Implementation: Lazy Loading Images
function lazyLoadImages() {
const images = document.querySelectorAll('img[data-src]');
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
observer.unobserve(img);
}
});
},
{ rootMargin: '200px' } // Start loading 200px before visible
);
images.forEach(img => observer.observe(img));
}Implementation: Infinite Scroll
function setupInfiniteScroll(sentinel, loadMore) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ rootMargin: '400px' } // Trigger before user reaches the end
);
observer.observe(sentinel); // sentinel = empty div at bottom of list
return () => observer.disconnect();
}Implementation: Scroll-Triggered Animations
function animateOnScroll() {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-in');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.2 }
);
document.querySelectorAll('.animate-on-scroll').forEach(el => {
observer.observe(el);
});
}Implementation: Analytics â Section View Tracking
function trackSectionVisibility() {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
analytics.track('section_viewed', {
section: entry.target.dataset.section,
timestamp: Date.now(),
});
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.5 } // Must be 50% visible to count
);
document.querySelectorAll('[data-section]').forEach(el => {
observer.observe(el);
});
}React Hook: useIntersectionObserver
function useIntersectionObserver(
options: IntersectionObserverInit = {}
): [RefCallback<Element>, boolean] {
const [isIntersecting, setIsIntersecting] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const ref = useCallback((node: Element | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
}
if (!node) return;
observerRef.current = new IntersectionObserver(
([entry]) => setIsIntersecting(entry.isIntersecting),
options
);
observerRef.current.observe(node);
}, [options.root, options.rootMargin, options.threshold]);
return [ref, isIntersecting];
}
// Usage
function LazyComponent() {
const [ref, isVisible] = useIntersectionObserver({ rootMargin: '200px' });
return (
<div ref={ref}>
{isVisible ? <ExpensiveContent /> : <Placeholder />}
</div>
);
}Why Not Scroll Handlers?
// â Scroll handler â fires 60+ times per second, calls getBoundingClientRect()
window.addEventListener('scroll', () => {
const rect = element.getBoundingClientRect(); // Forces layout!
if (rect.top < window.innerHeight) {
loadImage();
}
});
// â
IntersectionObserver â browser-optimized, no forced layout, no scroll jank
const observer = new IntersectionObserver(callback);
observer.observe(element);IntersectionObserver runs on a separate thread from main JavaScript execution. It doesn't cause layout thrashing and doesn't fire on every scroll pixel.
MutationObserver
Watches for changes to the DOM tree â element additions, removals, attribute changes, text changes:
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
switch (mutation.type) {
case 'childList':
mutation.addedNodes.forEach(node => {
console.log('Added:', node);
});
mutation.removedNodes.forEach(node => {
console.log('Removed:', node);
});
break;
case 'attributes':
console.log(`${mutation.attributeName} changed on`, mutation.target);
break;
case 'characterData':
console.log('Text changed:', mutation.target.textContent);
break;
}
});
});
observer.observe(document.body, {
childList: true, // Watch child additions/removals
attributes: true, // Watch attribute changes
characterData: true, // Watch text content changes
subtree: true, // Watch entire subtree, not just direct children
attributeFilter: ['class', 'data-state'], // Only these attributes
attributeOldValue: true, // Include previous attribute value
});Use Cases
// Auto-initialize third-party widgets when added to DOM
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node instanceof HTMLElement) {
node.querySelectorAll('[data-widget]').forEach(initWidget);
}
}
}
});
// Detect DOM changes from browser extensions (security)
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (isInjectedScript(node)) {
node.remove();
reportSecurityEvent();
}
}
}
}
});ResizeObserver
Watches for size changes on individual elements â not just the window:
const observer = new ResizeObserver((entries) => {
entries.forEach(entry => {
const { width, height } = entry.contentRect;
console.log(`${entry.target.id}: ${width}x${height}`);
// contentBoxSize gives more precise info
const [box] = entry.contentBoxSize;
console.log(`Inline: ${box.inlineSize}, Block: ${box.blockSize}`);
});
});
observer.observe(document.getElementById('container'));Use Cases
// Responsive component behavior (not just CSS media queries)
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width;
if (width < 400) {
entry.target.classList.add('compact');
} else {
entry.target.classList.remove('compact');
}
}
});
// Dynamic chart resizing
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
chart.resize(entry.contentRect.width, entry.contentRect.height);
}
});
observer.observe(chartContainer);React Hook: useElementSize
function useElementSize(): [RefCallback<Element>, { width: number; height: number }] {
const [size, setSize] = useState({ width: 0, height: 0 });
const observerRef = useRef<ResizeObserver | null>(null);
const ref = useCallback((node: Element | null) => {
if (observerRef.current) observerRef.current.disconnect();
if (!node) return;
observerRef.current = new ResizeObserver(([entry]) => {
setSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
});
observerRef.current.observe(node);
}, []);
return [ref, size];
}Why Not Window Resize?
window.addEventListener('resize') only fires when the window resizes. Container queries and CSS changes can resize elements without the window changing. ResizeObserver catches all of them.
PerformanceObserver
Watches for performance entries in real time â replacing manual performance.getEntries() polling:
// Monitor Largest Contentful Paint
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP:', lastEntry.startTime, lastEntry.element);
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
// Monitor Long Tasks (>50ms)
const taskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 100) {
console.warn(`Long task: ${entry.duration}ms`, entry);
}
}
});
taskObserver.observe({ entryTypes: ['longtask'] });
// Monitor Resource Loading
const resourceObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 1000) {
console.warn(`Slow resource: ${entry.name} took ${entry.duration}ms`);
}
}
});
resourceObserver.observe({ type: 'resource', buffered: true });Real-World: Web Vitals Monitoring
function monitorWebVitals(callback) {
// FCP
new PerformanceObserver((list) => {
const fcp = list.getEntries().find(e => e.name === 'first-contentful-paint');
if (fcp) callback({ metric: 'FCP', value: fcp.startTime });
}).observe({ type: 'paint', buffered: true });
// LCP
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lcp = entries[entries.length - 1];
callback({ metric: 'LCP', value: lcp.startTime });
}).observe({ type: 'largest-contentful-paint', buffered: true });
// CLS
let clsValue = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) clsValue += entry.value;
}
callback({ metric: 'CLS', value: clsValue });
}).observe({ type: 'layout-shift', buffered: true });
}Cleanup Patterns
Always disconnect observers to prevent memory leaks:
// Vanilla JS
const observer = new IntersectionObserver(callback);
observer.observe(element);
// ... later
observer.disconnect();
// React useEffect
useEffect(() => {
const observer = new IntersectionObserver(callback, options);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);Interview Signal
Senior candidates demonstrate:
- API knowledge â All four observers and their specific use cases
- Performance reasoning â Why observers beat scroll handlers and polling
- Implementation skill â Lazy loading, infinite scroll, resize-aware components
- React integration â Custom hooks with proper cleanup
- Production patterns â Web Vitals monitoring, security detection, analytics tracking