JavaScript Observer APIs: IntersectionObserver, ResizeObserver, MutationObserver
For years, the standard way to know when an element became visible was a scroll event listener with manual getBoundingClientRect math. When something resized: window.resize plus the same math. When the DOM changed: polling. All three patterns were slow, fragile, and prone to layout thrashing.
The observer APIs replace them with proper event-driven primitives: IntersectionObserver for visibility, ResizeObserver for size changes, MutationObserver for DOM mutations. They use the browser's internal data — no measurement code in your hot path — and they batch callbacks for performance.
This lesson covers all three, with the practical patterns each one unlocks.
IntersectionObserver #
Fires when an element enters or leaves the viewport (or a custom "root" element).
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
console.log(entry.target.id, 'is now visible');
}
}
});
observer.observe(document.getElementById('hero'));
observer.observe(document.getElementById('footer'));
The browser calls your callback when the intersection ratio (how much of the element is visible) crosses a threshold. By default the threshold is 0 — fires when entering or leaving at all.
Options #
new IntersectionObserver(callback, {
root: null, // the viewport by default; can be any scrollable ancestor
rootMargin: '100px', // expand or shrink the root's bounds
threshold: 0.5, // fire when 50% visible
threshold: [0, 0.25, 0.5, 0.75, 1.0], // fire at multiple ratios
});
rootMargin is the most underused. It lets you trigger something before the element is technically visible — useful for prefetching:
new IntersectionObserver(prefetch, {
rootMargin: '500px', // start loading 500px before it enters the viewport
});
Lazy-loading images (the canonical use) #
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
io.unobserve(img);
}
}
}, { rootMargin: '200px' });
for (const img of document.querySelectorAll('img[data-src]')) {
io.observe(img);
}
Images get their src set only when they're within 200 px of the viewport. unobserve prevents firing again after the load.
Note: the browser also supports loading="lazy" as an HTML attribute, which handles this for you with no JS:
<img src="image.jpg" loading="lazy" alt="...">
Use the attribute for plain image lazy-loading; reach for IntersectionObserver for anything more complex (custom thresholds, prefetching data, infinite scroll, scroll-driven animations).
Infinite scroll #
const sentinel = document.querySelector('#load-more-sentinel');
const io = new IntersectionObserver(async ([entry]) => {
if (entry.isIntersecting) {
await loadMorePosts();
}
});
io.observe(sentinel);
Put a small invisible element at the bottom of your list; observe it; load more when it scrolls into view. Cleaner than the old scroll-event approach and works without measuring positions.
Scroll-driven animations #
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
entry.target.classList.toggle('in-view', entry.isIntersecting);
}
}, { threshold: 0.2 });
document.querySelectorAll('.fade-in').forEach(el => io.observe(el));
Fade in elements as they enter the viewport. The CSS does the actual animation; IO just toggles the class.
Visibility tracking #
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) trackImpression(entry.target.dataset.id);
}
}, { threshold: 0.5 });
For analytics — track when ads, articles, or feature blocks were actually seen.
ResizeObserver #
Fires when an element's size changes.
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
console.log(entry.target.id, width, 'x', height);
}
});
ro.observe(document.getElementById('chart'));
Fires when the observed element resizes for ANY reason — window resize, parent layout change, content change, even CSS-only animation of width/height.
Use cases:
- Responsive components: re-render a chart or canvas at its new size
- Container queries: before CSS container queries were supported, this was the polyfill
- Sticky elements: recompute positions when the parent resizes
Content vs border-box #
ro.observe(el, { box: 'content-box' }); // default — excludes padding/border
ro.observe(el, { box: 'border-box' }); // includes padding/border
ro.observe(el, { box: 'device-pixel-content-box' }); // device pixels — for canvas
For canvas elements where you want pixel-perfect drawing, device-pixel-content-box accounts for browser zoom.
Avoiding loops #
A mistake to avoid: changing the size from inside the callback can cause an infinite loop. The browser will throw a "ResizeObserver loop" warning. The fix is usually to debounce or to only change the size when it's actually different.
let last;
const ro = new ResizeObserver((entries) => {
const w = entries[0].contentRect.width;
if (w !== last) {
last = w;
redraw(w);
}
});
MutationObserver #
Fires when DOM nodes are added, removed, or attributes/text content change.
const mo = new MutationObserver((mutations) => {
for (const m of mutations) {
console.log(m.type, m.target, m.addedNodes, m.removedNodes);
}
});
mo.observe(document.getElementById('app'), {
childList: true, // direct children added/removed
subtree: true, // include all descendants
attributes: true, // attribute changes
characterData: true,// text content changes
});
Use cases:
- Watching for content from third-party scripts (chat widgets, ad networks)
- Implementing custom directives in a framework
- Detecting when an element you don't control appears or disappears
- Auto-saving rich text as the user types
- Building tools (DevTools-like inspectors, accessibility checkers)
Disconnecting #
mo.disconnect();
Always disconnect when you're done — observers with subtree: true and attributes: true can fire frequently and shouldn't outlive their purpose.
Filtering by attribute #
mo.observe(el, {
attributes: true,
attributeFilter: ['data-state', 'aria-expanded'],
attributeOldValue: true, // include the previous value
});
Narrow the firing to specific attributes and get the old value alongside the new.
Why observers beat the old approaches #
The pre-observer ways of doing these jobs:
| Job | Old way | Problem |
|---|---|---|
| Detect visibility | scroll listener + getBoundingClientRect() |
Fires on every scroll pixel; layout thrashing |
| Detect size change | window resize listener + manual measurement | Doesn't fire when only the element (not the window) resizes |
| Detect DOM change | setInterval polling | Misses fast changes; wastes CPU when nothing changes |
Observers fire only when there's actually a relevant change, and they batch callbacks across the frame. No measurement code on the hot path, no polling, no missed events.
The four observers (the lesser-known one) #
In addition to the three core observers, there's also:
PerformanceObserver— for collecting Web Vitals (LCP, CLS, FID), long-task warnings, navigation metrics.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'largest-contentful-paint') {
console.log('LCP:', entry.startTime);
}
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
For anything performance-related (RUM, custom timing measurements), PerformanceObserver is the right tool. We touched on this in passing — full coverage belongs in a performance lesson.
A summary #
IntersectionObserver: visibility changes (lazy load, infinite scroll, fade-ins, impressions). UserootMarginfor prefetching.ResizeObserver: size changes (responsive components, canvas redraws). Beware of loops.MutationObserver: DOM changes (watching third-party content, custom directives).PerformanceObserver: performance entries (LCP, long tasks).- Always
disconnect()observers when their work is done. - Built into all modern browsers since ~2018. No polyfills needed in 2026.
What's next #
Lesson 11.3 covers Web Components — Custom Elements, Shadow DOM, slots, and how to build framework-agnostic components that work in any HTML.
Try it yourself #
The IntersectionObserver pattern is the easiest to feel. Predict what fires:
A. One IntersectionObserver, one
observe() per imageB. 100 separate IntersectionObservers, each watching one image
Which is more efficient?
Each observer has its own internal bookkeeping. Sharing one observer across all images batches all the intersection-change events into a single callback, lets the browser optimize all the rect checks together, and uses less memory.
The pattern: create observers per use-case, not per element. One lazy-load observer for all images on the page; one infinite-scroll observer for the list sentinel; one fade-in observer for the animation classes.
Observers are cheap to create, but cheaper still to share. Reuse instead of creating per-element.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


