Browser APIs

Course: JavaScript Advanced

Lesson 14: Browser APIs

Level

Advanced

Estimated Reading Time

10–15 minutes

Goal

By the end of this lesson, the learner will be able to:

  • Use IntersectionObserver to implement lazy loading and trigger animations when elements enter the viewport
  • Use MutationObserver to react to dynamic DOM changes without polling
  • Use ResizeObserver to respond to element size changes for responsive component logic

Frontend Development Context

Modern browser APIs have replaced entire categories of scroll listeners, polling loops, and setInterval hacks. Before IntersectionObserver, lazy loading images required attaching scroll listeners, measuring scroll position, and calculating element offsets on every scroll event — expensive and error-prone. Before MutationObserver, reacting to third-party DOM changes meant polling with setInterval. These observer APIs are how browsers signal events efficiently, without constant polling from JavaScript.

At the advanced level, knowing these APIs means you can implement lazy loading, infinite scroll, animated on-scroll reveals, responsive component behavior, and DOM watching patterns that are efficient, GC-friendly (observers hold weak references to their targets), and easy to disconnect when no longer needed. These are the building blocks of modern performance-conscious frontend development.

Explanation

IntersectionObserver observes whether target elements are intersecting the viewport (or a specified root element). The callback fires when the intersection ratio crosses any of the specified thresholds. This is far more efficient than scroll-based solutions — the browser does the intersection math off the main thread and only fires your callback at meaningful moments. The observer is created once and can watch multiple elements simultaneously.

The callback receives an array of IntersectionObserverEntry objects. Each entry has isIntersecting (whether the element is in view), intersectionRatio (how much of it is visible, 0 to 1), and target (the observed element). Calling observer.unobserve(entry.target) inside the callback after the first intersection is a common pattern for lazy loading — once an image loads, you no longer need to watch it.

MutationObserver watches for changes to the DOM tree. It can observe added or removed child nodes, attribute changes, and text content changes. The callback receives an array of MutationRecord objects describing each change. Unlike IntersectionObserver, MutationObserver is synchronous after mutations — the callback fires in a microtask after the current task completes, before the browser paints. This makes it useful for reacting to third-party DOM changes, watching for dynamically injected content, or observing attribute changes on elements you don't control.

ResizeObserver fires when an element's size changes — specifically, its content or border box dimensions. Unlike the window.resize event, ResizeObserver watches individual elements and fires even when their size changes due to CSS changes, parent container resizing, or content changes that cause reflow. Each entry has contentRect with width, height, top, and left. This is used for responsive component logic, canvas sizing, or chart libraries that need to know the container size.

All three observers share a similar API: create with a callback, call observe(element) to start watching, unobserve(element) to stop watching one target, and disconnect() to stop watching all targets and release resources.

Code Example

This example implements lazy image loading with IntersectionObserver, DOM change detection with MutationObserver, and a responsive chart container with ResizeObserver.

// --- IntersectionObserver: lazy image loading ---
function createLazyLoader() {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;

      const img = entry.target;
      const src = img.dataset.src;

      if (src) {
        img.src = src;
        img.removeAttribute("data-src");
        img.classList.add("loaded");
      }

      observer.unobserve(img); // stop watching once loaded
    });
  }, {
    rootMargin: "200px 0px", // start loading 200px before entering viewport
    threshold: 0,
  });

  return {
    observe(img) { observer.observe(img); },
    disconnect() { observer.disconnect(); },
  };
}

const lazyLoader = createLazyLoader();
document.querySelectorAll("img[data-src]").forEach(img => {
  lazyLoader.observe(img);
});

// --- MutationObserver: react to dynamic content injection ---
function watchForNewMessages(container, onNewMessage) {
  const observer = new MutationObserver(mutations => {
    mutations.forEach(mutation => {
      mutation.addedNodes.forEach(node => {
        if (node.nodeType === Node.ELEMENT_NODE &&
            node.classList.contains("message")) {
          onNewMessage(node);
        }
      });
    });
  });

  observer.observe(container, {
    childList: true,  // watch for added/removed children
    subtree: false,   // don't watch deep descendants
  });

  return () => observer.disconnect(); // return cleanup function
}

const chatBox = document.querySelector("#chat");
const stopWatching = watchForNewMessages(chatBox, (messageEl) => {
  console.log("New message received:", messageEl.textContent);
  messageEl.classList.add("highlight");
});

// --- ResizeObserver: responsive chart container ---
function createResponsiveChart(container) {
  let width = container.clientWidth;
  let height = container.clientHeight;

  function redraw() {
    console.log(`Redrawing chart at ${width}x${height}`);
    // In a real chart: clear canvas, recalculate scales, redraw
  }

  const resizeObserver = new ResizeObserver(entries => {
    for (const entry of entries) {
      const { width: newWidth, height: newHeight } = entry.contentRect;
      if (newWidth !== width || newHeight !== height) {
        width = newWidth;
        height = newHeight;
        redraw();
      }
    }
  });

  resizeObserver.observe(container);
  redraw(); // initial draw

  return {
    destroy() { resizeObserver.disconnect(); },
  };
}

Code Explanation

createLazyLoader wraps IntersectionObserver in a factory. The observer's callback checks isIntersecting first — entries can fire when elements leave the viewport too if the threshold crosses in that direction. When an image enters the viewport within the 200px root margin, its data-src attribute is moved to src, triggering the actual image load. observer.unobserve(img) is called immediately after to stop watching that specific image — there's no reason to keep observing a loaded image. The rootMargin: "200px 0px" makes images start loading 200px before they actually enter the visible area, so there's no visible pop-in.

watchForNewMessages sets up a MutationObserver on a chat container. childList: true watches only for direct child additions and removals. When a new node is added, the callback checks if it's an element with the class "message" before calling onNewMessage. The function returns a cleanup callback, following the pattern of returning disposal functions for manual lifecycle management.

createResponsiveChart uses ResizeObserver to track the chart container's dimensions. The observer fires whenever the container's contentRect changes — due to window resize, CSS changes, or parent layout shifts. The callback compares old and new dimensions to avoid unnecessary redraws if the observer fires without actual size changes. disconnect() is available through the returned destroy method for cleanup.

All three observers are disconnected when no longer needed, preventing the memory leak that would occur if they held references to DOM elements after those elements are removed from the page.

Pattern Highlights

Positive Pattern: Call unobserve(element) inside an IntersectionObserver callback after the desired action has been taken (e.g., image loaded, animation triggered) — continuing to observe elements that don't need further observation wastes browser resources.

⚠️ Neutral Note: MutationObserver callbacks fire as microtasks after DOM mutations. If your callback makes further DOM changes, those will trigger additional callbacks — be careful not to create infinite mutation loops.

Negative Pattern: Do not use scroll event listeners to implement lazy loading or viewport detection — they fire on the main thread at full scroll rate (potentially 60+ times per second) and require expensive manual intersection math; IntersectionObserver handles this off the main thread automatically.

Common Mistakes

Mistake 1: Forgetting to disconnect observers when elements are removed

Observers hold references to their callback and observed elements. If you remove a component from the DOM without disconnecting its observers, both the observer and the component's closure are leaked.

function setupComponent(element) {
  const observer = new ResizeObserver(entries => {
    updateLayout(element); // holds reference to element
  });
  observer.observe(element);
  // Missing: return () => observer.disconnect();
}

Always return or store a cleanup function that calls observer.disconnect() when the component is destroyed.

Mistake 2: Using MutationObserver to watch attributes without specifying subtree correctly

Watching for attribute changes requires attributes: true, and watching deep descendants requires subtree: true. Forgetting these options means the observer silently fails to fire.

observer.observe(element, {
  childList: true,
  // forgot: attributes: true — attribute changes won't be observed
  // forgot: subtree: true — only direct children watched, not grandchildren
});

Always explicitly configure the MutationObserver init object with the exact set of options you need: childList, attributes, attributeFilter, characterData, subtree.

Mistake 3: Performing expensive work synchronously inside observer callbacks

Observer callbacks can fire many times, and expensive synchronous work inside them defeats their performance benefit.

const observer = new ResizeObserver(entries => {
  for (const entry of entries) {
    recalculateEntireLayout(); // expensive synchronous operation on every resize event
  }
});

Debounce or throttle expensive operations inside observer callbacks, or use requestAnimationFrame to defer the heavy work to the next render frame.

Quick Recap

  • IntersectionObserver fires when elements enter or leave the viewport (or a root element), without any scroll event listeners — use it for lazy loading, infinite scroll, and on-scroll animations.
  • MutationObserver fires when the DOM changes (children added/removed, attributes changed) — use it to react to third-party DOM mutations or dynamically injected content.
  • ResizeObserver fires when an element's dimensions change — use it for responsive component logic, canvas sizing, and chart redraws without relying on the window.resize event.
  • Always call disconnect() (or unobserve() for specific elements) when cleanup is needed — observers hold references to their targets and can cause memory leaks if left active after components are removed.

Ready to test your understanding? Take the quiz to reinforce what you learned.

© 2026 Ant Skillsv.26.07.07-23:01