Advanced Features
Observers
IntersectionObserver, MutationObserver, ResizeObserver, and PerformanceObserver — efficient, event-driven DOM and performance monitoring
Observers
Browser observer APIs provide an efficient, callback-based way to react to changes in the DOM, viewport, element sizes, and performance metrics — replacing costly polling and scroll-event patterns.
Problems Solved
| Problem | Traditional Approach | Observer Solution |
|---|---|---|
| Detect element visibility | scroll event + getBoundingClientRect() | IntersectionObserver |
| Watch DOM changes | Polling or deprecated Mutation Events | MutationObserver |
| Respond to element resize | resize event (window only) | ResizeObserver |
| Collect performance metrics | Manual timing with performance.now() | PerformanceObserver |
Observer Pattern Overview
All observers share the same pattern:
1. Create observer with callback
const observer = new XxxObserver(callback);
2. Start observing targets
observer.observe(element);
3. Callback fires with entries
callback(entries, observer) { ... }
4. Stop observing
observer.unobserve(element); // one target
observer.disconnect(); // all targetsIntersectionObserver
Detects when an element enters or exits the viewport (or a specified container).
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 container
rootMargin: '0px', // margin around root (e.g., '100px' to trigger early)
threshold: [0, 0.5, 1] // fire at 0%, 50%, 100% visibility
});
observer.observe(document.querySelector('#target'));Entry Properties
| Property | Type | Description |
|---|---|---|
isIntersecting | boolean | Whether element is within root |
intersectionRatio | number | 0-1, fraction of element visible |
boundingClientRect | DOMRect | Element's bounding rectangle |
intersectionRect | DOMRect | Visible portion of element |
rootBounds | DOMRect | Root element's bounding rectangle |
target | Element | The observed element |
time | number | Timestamp of intersection change |
Lazy Loading Images
function lazyLoadImages() {
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
if (img.dataset.srcset) img.srcset = img.dataset.srcset;
img.classList.add('loaded');
obs.unobserve(img); // Stop observing once loaded
}
});
}, {
rootMargin: '200px 0px' // Start loading 200px before viewport
});
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img);
});
}Infinite Scroll
function infiniteScroll(container, loadMore) {
// Observe a sentinel element at the bottom of the list
const sentinel = document.createElement('div');
sentinel.className = 'scroll-sentinel';
container.appendChild(sentinel);
let loading = false;
const observer = new IntersectionObserver(async (entries) => {
if (entries[0].isIntersecting && !loading) {
loading = true;
const hasMore = await loadMore();
loading = false;
if (!hasMore) {
observer.disconnect();
sentinel.remove();
}
}
}, {
root: container, // or null for viewport
rootMargin: '100px'
});
observer.observe(sentinel);
return () => observer.disconnect();
}
// Usage
infiniteScroll(
document.querySelector('.list'),
async () => {
const items = await fetchNextPage();
appendItems(items);
return items.length > 0; // false stops the observer
}
);Scroll-Linked Animations (Fade In)
function fadeInOnScroll(selector) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1 });
document.querySelectorAll(selector).forEach(el => observer.observe(el));
}.fade-in { opacity: 0; transform: translateY(20px); transition: all 0.6s ease; }
.fade-in.visible { opacity: 1; transform: translateY(0); }Analytics: Viewport Tracking
function trackImpressions(elements, onImpression) {
const seen = new WeakSet();
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5 && !seen.has(entry.target)) {
seen.add(entry.target);
onImpression(entry.target.dataset.trackingId);
}
});
}, { threshold: 0.5 });
elements.forEach(el => observer.observe(el));
return () => observer.disconnect();
}MutationObserver
Watches for changes in the DOM tree — attribute modifications, child additions/removals, text changes.
Basic Usage
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.data);
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 all descendants
attributeFilter: ['class', 'style'], // Only these attributes
attributeOldValue: true, // Include previous value
characterDataOldValue: true, // Include previous text
});Configuration Options
| Option | Type | Description |
|---|---|---|
childList | boolean | Watch for added/removed child nodes |
attributes | boolean | Watch attribute changes |
characterData | boolean | Watch text node changes |
subtree | boolean | Extend observation to all descendants |
attributeFilter | string[] | Only watch specific attributes |
attributeOldValue | boolean | Record previous attribute value |
characterDataOldValue | boolean | Record previous text value |
Auto-Initialize Components on DOM Insert
// Automatically initialize components when they're added to the DOM
function autoInit(selector, initFn) {
// Initialize existing elements
document.querySelectorAll(selector).forEach(initFn);
// Watch for new elements
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
// Check the node itself
if (node.matches(selector)) initFn(node);
// Check descendants
node.querySelectorAll?.(selector).forEach(initFn);
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}
// Usage: auto-initialize all [data-tooltip] elements
autoInit('[data-tooltip]', (el) => {
el.addEventListener('mouseenter', showTooltip);
el.addEventListener('mouseleave', hideTooltip);
});DOM Diff / Change Tracking
// Track all DOM changes for undo/redo
function trackChanges(root) {
const history = [];
const observer = new MutationObserver((mutations) => {
history.push(mutations.map(m => ({
type: m.type,
target: m.target,
addedNodes: [...m.addedNodes],
removedNodes: [...m.removedNodes],
attributeName: m.attributeName,
oldValue: m.oldValue,
})));
});
observer.observe(root, {
childList: true,
attributes: true,
characterData: true,
subtree: true,
attributeOldValue: true,
characterDataOldValue: true,
});
return {
getHistory: () => [...history],
disconnect: () => observer.disconnect(),
};
}ResizeObserver
Monitors changes to an element's content box or border box dimensions.
Basic Usage
const observer = new ResizeObserver((entries) => {
entries.forEach(entry => {
const { width, height } = entry.contentRect;
console.log(`${entry.target.id}: ${width} x ${height}`);
// Also available:
// entry.borderBoxSize[0].inlineSize / blockSize
// entry.contentBoxSize[0].inlineSize / blockSize
});
});
observer.observe(document.querySelector('#container'));Responsive Components (Container Queries Alternative)
function responsiveComponent(element) {
const observer = new ResizeObserver((entries) => {
const { inlineSize: width } = entries[0].contentBoxSize[0];
element.classList.remove('compact', 'regular', 'wide');
if (width < 400) {
element.classList.add('compact');
} else if (width < 800) {
element.classList.add('regular');
} else {
element.classList.add('wide');
}
});
observer.observe(element);
return () => observer.disconnect();
}Auto-Resize Canvas / Charts
function autoResizeCanvas(canvas) {
const ctx = canvas.getContext('2d');
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
drawChart(ctx, width, height); // Re-draw at new size
});
observer.observe(canvas.parentElement);
return () => observer.disconnect();
}Text Truncation Detection
function watchTruncation(element, onTruncationChange) {
let wasTruncated = false;
const observer = new ResizeObserver(() => {
const isTruncated = element.scrollWidth > element.clientWidth;
if (isTruncated !== wasTruncated) {
wasTruncated = isTruncated;
onTruncationChange(isTruncated);
}
});
observer.observe(element);
return () => observer.disconnect();
}
// Show tooltip only when text is truncated
watchTruncation(nameElement, (isTruncated) => {
nameElement.title = isTruncated ? nameElement.textContent : '';
});PerformanceObserver
Provides access to performance timeline entries as they're recorded by the browser.
Basic Usage
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
console.log(`${entry.entryType}: ${entry.name} — ${entry.duration}ms`);
});
});
observer.observe({
entryTypes: ['paint', 'largest-contentful-paint', 'layout-shift', 'longtask']
});Entry Types
| Entry Type | What It Measures | Key Properties |
|---|---|---|
paint | FP, FCP | name, startTime |
largest-contentful-paint | LCP | startTime, size, element |
layout-shift | CLS | value, hadRecentInput |
longtask | Tasks > 50ms | duration, startTime |
event | Input delay (INP) | duration, processingStart |
resource | Resource loading | duration, transferSize |
navigation | Page load timing | domContentLoadedEventEnd |
mark | Custom marks | startTime |
measure | Custom measures | duration |
Core Web Vitals Monitoring
function observeWebVitals(callback) {
// LCP
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lcp = entries[entries.length - 1]; // Last entry is final LCP
callback('LCP', lcp.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
// CLS
let clsValue = 0;
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (!entry.hadRecentInput) {
clsValue += entry.value;
}
});
callback('CLS', clsValue);
}).observe({ type: 'layout-shift', buffered: true });
// Long Tasks (affects INP/TBT)
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (entry.duration > 50) {
callback('LongTask', entry.duration);
}
});
}).observe({ type: 'longtask', buffered: true });
}
observeWebVitals((metric, value) => {
console.log(`${metric}: ${value}`);
// Send to analytics endpoint
});Slow Resource Detection
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (entry.duration > 1000) {
console.warn(`Slow resource: ${entry.name} (${Math.round(entry.duration)}ms)`);
console.warn(` DNS: ${Math.round(entry.domainLookupEnd - entry.domainLookupStart)}ms`);
console.warn(` Connect: ${Math.round(entry.connectEnd - entry.connectStart)}ms`);
console.warn(` TTFB: ${Math.round(entry.responseStart - entry.requestStart)}ms`);
console.warn(` Download: ${Math.round(entry.responseEnd - entry.responseStart)}ms`);
}
});
}).observe({ type: 'resource', buffered: true });Comparison
| Observer | Target | Fires When | Typical Use |
|---|---|---|---|
| IntersectionObserver | Element + viewport/container | Visibility changes | Lazy load, infinite scroll, analytics |
| MutationObserver | DOM node + subtree | DOM mutates | Auto-init, change tracking, WYSIWYG |
| ResizeObserver | Element | Dimensions change | Responsive components, canvas, charts |
| PerformanceObserver | Performance timeline | Entries recorded | Web vitals, slow resource detection |
Framework Integration
React Hook
function useIntersection(ref, options = {}) {
const [entry, setEntry] = useState(null);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(
([entry]) => setEntry(entry),
options
);
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref, options.threshold, options.rootMargin]);
return entry;
}
// Usage
function LazyImage({ src }) {
const ref = useRef();
const entry = useIntersection(ref, { rootMargin: '200px' });
return (
<img ref={ref} src={entry?.isIntersecting ? src : undefined} />
);
}function useResizeObserver(ref) {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!ref.current) return;
const observer = new ResizeObserver(([entry]) => {
setSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
});
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref]);
return size;
}Best Practices
- Always call
disconnect()orunobserve()in cleanup (ReactuseEffectreturn,disconnectedCallback, etc.) - Use
rootMarginwith IntersectionObserver to trigger actions before elements become visible - Avoid heavy computation inside observer callbacks — debounce or use
requestAnimationFrameif needed - Use
{ buffered: true }with PerformanceObserver to get entries recorded before the observer was created - MutationObserver callbacks fire as microtasks — be careful about causing infinite loops by mutating observed nodes inside the callback