JavaScript Interview Prep
DOM & Browser APIs

Intersection Observer & Mutation Observer

Watch, Don't Poll

LinkedIn Hook

Old frontend: listen to scroll, read getBoundingClientRect() 60 times a second, hope the main thread survives.

Modern frontend: register an Intersection Observer and let the browser tell you exactly when an element becomes visible.

Same story for DOM mutations — instead of polling with setInterval to see "did that node get inserted yet?", a Mutation Observer fires a precise record of every change.

These two APIs are the difference between a laggy scroll experience and a buttery one, between a memory-leaking SPA and one that cleans up after itself.

In this lesson you'll build a lazy image loader, an infinite scroll sentinel, an animate-on-scroll effect, and a DOM change watcher — all with zero scroll handlers and zero polling.

Read the full lesson -> [link]

#JavaScript #IntersectionObserver #MutationObserver #Frontend #WebPerformance #InterviewPrep #WebDevelopment


Intersection Observer & Mutation Observer thumbnail


What You'll Learn

  • How to replace scroll handlers with IntersectionObserver for lazy load, infinite scroll, and animate-on-scroll
  • How to watch for DOM changes with MutationObserver — child list, attributes, text
  • Cleanup patterns (unobserve, disconnect) that keep long-lived pages leak-free

Security Camera vs Change Log

Think of Intersection Observer as a security camera at a doorway — it only triggers when something enters or exits the frame, instead of constantly checking "is anything there?" And Mutation Observer is like a change log that automatically records every modification to a document.

IntersectionObserver — Detecting Visibility

Instead of listening to scroll events and calculating positions (expensive!), IntersectionObserver efficiently tells you when an element enters or exits the viewport.

Lazy Image Loading with IntersectionObserver

// HTML: <img data-src="photo.jpg" class="lazy" alt="Photo">

function lazyLoadImages() {
  const observer = new IntersectionObserver((entries, obs) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;     // load the real image
        img.classList.remove("lazy");
        img.classList.add("loaded");
        obs.unobserve(img);            // stop watching this image
      }
    });
  }, {
    rootMargin: "100px", // start loading 100px before visible
    threshold: 0         // trigger as soon as even 1px is visible
  });

  // Observe all lazy images
  document.querySelectorAll("img.lazy").forEach(img => {
    observer.observe(img);
  });
}

lazyLoadImages();

Infinite Scroll with IntersectionObserver

function setupInfiniteScroll() {
  const sentinel = document.getElementById("scroll-sentinel");
  let page = 1;
  let loading = false;

  const observer = new IntersectionObserver(async (entries) => {
    if (entries[0].isIntersecting && !loading) {
      loading = true;
      page++;

      const items = await fetchItems(page);
      const container = document.getElementById("feed");
      const fragment = document.createDocumentFragment();

      items.forEach(item => {
        const div = document.createElement("div");
        div.className = "feed-item";
        div.textContent = item.title;
        fragment.appendChild(div);
      });

      container.insertBefore(fragment, sentinel);
      loading = false;
    }
  }, { threshold: 1.0 });

  observer.observe(sentinel);
}

Animate-on-Scroll with IntersectionObserver

const animateObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add("animate-in");
      // Optional: entry.target.style.opacity = entry.intersectionRatio;
    } else {
      entry.target.classList.remove("animate-in");
    }
  });
}, {
  threshold: [0, 0.25, 0.5, 0.75, 1.0] // fire at multiple visibility levels
});

document.querySelectorAll(".animate-on-scroll").forEach(el => {
  animateObserver.observe(el);
});

IntersectionObserver Options

const observer = new IntersectionObserver(callback, {
  root: null,             // null = viewport (or specify a scrollable container)
  rootMargin: "0px",      // margin around root (like CSS margin: "10px 20px")
  threshold: 0            // 0 = any pixel, 1.0 = fully visible, [0, 0.5, 1] = multiple
});

// Methods
observer.observe(element);     // start watching
observer.unobserve(element);   // stop watching one element
observer.disconnect();         // stop watching everything

MutationObserver — Watching DOM Changes

MutationObserver lets you react to DOM modifications — child additions/removals, attribute changes, text content changes — without polling.

// Watch for DOM changes
const targetNode = document.getElementById("app");

const mutationObserver = 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(
          `Attribute '${mutation.attributeName}' changed on`,
          mutation.target
        );
        break;

      case "characterData":
        console.log("Text changed to:", mutation.target.data);
        break;
    }
  });
});

// Start observing
mutationObserver.observe(targetNode, {
  childList: true,       // watch for added/removed children
  attributes: true,      // watch for attribute changes
  characterData: true,   // watch for text content changes
  subtree: true,         // watch entire subtree, not just direct children
  attributeFilter: ["class", "data-status"], // only these attributes
  attributeOldValue: true, // include old attribute value in mutation record
  characterDataOldValue: true
});

// Stop observing
mutationObserver.disconnect();

Practical Example: Auto-Highlight New Content

// Highlight any new elements added to a container
function watchForNewContent(container) {
  const observer = new MutationObserver((mutations) => {
    mutations.forEach(mutation => {
      mutation.addedNodes.forEach(node => {
        if (node.nodeType === Node.ELEMENT_NODE) {
          node.classList.add("highlight-new");
          // Remove highlight after animation
          setTimeout(() => node.classList.remove("highlight-new"), 2000);
        }
      });
    });
  });

  observer.observe(container, { childList: true, subtree: true });

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

const cleanup = watchForNewContent(document.getElementById("notifications"));

// Later, when no longer needed:
// cleanup();

Cleanup Pattern for Both Observers

// In a component lifecycle (React-like pattern)
class LazySection {
  constructor(element) {
    this.element = element;
    this.intersectionObs = null;
    this.mutationObs = null;
  }

  init() {
    // IntersectionObserver
    this.intersectionObs = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        this.loadContent();
        this.intersectionObs.disconnect(); // one-time load
      }
    });
    this.intersectionObs.observe(this.element);

    // MutationObserver
    this.mutationObs = new MutationObserver(this.handleMutations.bind(this));
    this.mutationObs.observe(this.element, { childList: true, subtree: true });
  }

  handleMutations(mutations) {
    // process changes
  }

  destroy() {
    // ALWAYS clean up observers to prevent memory leaks
    if (this.intersectionObs) this.intersectionObs.disconnect();
    if (this.mutationObs) this.mutationObs.disconnect();
  }
}

Intersection Observer & Mutation Observer visual 1


Common Mistakes

  • Leaving observers connected forever — in a long-lived SPA this leaks memory and keeps detached DOM nodes alive. Call unobserve(el) for one-offs (like a lazy image that's loaded) and disconnect() on teardown.
  • Setting threshold: 1.0 on an element taller than the viewport for infinite scroll — it can never be 100% visible, so the callback never fires. Use threshold: 0 with a small sentinel element, or rootMargin.
  • Observing with subtree: true across a huge tree and reacting to every mutation — batch and filter inside the callback (e.g., check mutation.type and mutation.addedNodes) to avoid runaway work.

Interview Questions

Q: What is IntersectionObserver and why is it better than scroll events?

IntersectionObserver asynchronously detects when an element enters or exits the viewport (or another container). It's better than scroll events because: 1) it runs off the main thread, 2) it doesn't fire on every scroll pixel (only at thresholds), 3) no manual getBoundingClientRect calculations, 4) better performance with many observed elements.

Q: What is MutationObserver used for?

MutationObserver watches for changes to the DOM — added/removed nodes, attribute changes, and text content changes. Use cases: auto-formatting injected content, accessibility tools that react to DOM changes, frameworks that track DOM state, analytics that monitor dynamic content.

Q: How do you prevent memory leaks with observers?

Always call observer.disconnect() when you're done observing. For IntersectionObserver, use unobserve(element) for individual elements (e.g., after lazy-loading an image). In component-based architectures, disconnect in the teardown/unmount lifecycle method.

Q: Name 3 use cases for IntersectionObserver.

(1) Lazy-loading images when they approach the viewport, (2) infinite scroll via a sentinel element below the feed, (3) animate-on-scroll reveals using the isIntersecting flag and optional intersectionRatio for progressive effects.

Q: What do root, rootMargin, and threshold do in IntersectionObserver?

root is the container considered the "viewport" for intersection (null = the actual viewport). rootMargin grows or shrinks the root's bounding box using CSS margin syntax — useful for firing "early". threshold is the visibility ratio that triggers the callback: 0 (any pixel), 1.0 (fully visible), or an array of multiple trip points.

Q: How do you make a MutationObserver watch attribute changes only on specific attributes?

Pass attributes: true plus attributeFilter: ["class", "data-status"] in the options. This limits mutation records to just those attribute names and skips everything else.


Quick Reference — Cheat Sheet

OBSERVERS — QUICK MAP

IntersectionObserver:
  const io = new IntersectionObserver(cb, {
    root: null,          // viewport or a scrollable el
    rootMargin: "0px",   // CSS-style margin around root
    threshold: 0         // 0..1 or [0, 0.5, 1]
  });
  io.observe(el);
  io.unobserve(el);
  io.disconnect();

  Entry fields:
    entry.isIntersecting   // bool
    entry.intersectionRatio // 0..1
    entry.target            // the element

  Use cases:
    - lazy image load  (unobserve after load)
    - infinite scroll  (observe sentinel)
    - animate on scroll (multi-threshold)

MutationObserver:
  const mo = new MutationObserver(records => {...});
  mo.observe(target, {
    childList: true,     // added/removed children
    attributes: true,    // attribute changes
    characterData: true, // text changes
    subtree: true,       // watch descendants too
    attributeFilter: ["class"],
    attributeOldValue: true,
    characterDataOldValue: true
  });
  mo.disconnect();

  Record types: "childList" | "attributes" | "characterData"

Cleanup rule:
  ALWAYS disconnect() / unobserve() in teardown to avoid leaks.

Previous: requestAnimationFrame Next: Module Pattern


This is Lesson 11.6 of the JavaScript Interview Prep Course — 14 chapters, 87 lessons.

On this page