JavaScript Observer APIs: IntersectionObserver, ResizeObserver, MutationObserver

Link copied
JavaScript Observer APIs: IntersectionObserver, ResizeObserver, MutationObserver

JavaScript Observer APIs: IntersectionObserver, ResizeObserver, MutationObserver

JS Tutorial Module 11: Beyond DOM Lesson 11.2

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). Use rootMargin for 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:

YouA page has 100 lazy-loaded images. Two approaches:

A. One IntersectionObserver, one observe() per image
B. 100 separate IntersectionObservers, each watching one image

Which is more efficient?
ClaudeApproach A — one observer watching 100 elements — is significantly 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

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *