Intersection Observer & Mutation Observer
Watch, Don't Poll
LinkedIn Hook
Old frontend: listen to
scroll, readgetBoundingClientRect()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
setIntervalto 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
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();
}
}
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) anddisconnect()on teardown. - Setting
threshold: 1.0on an element taller than the viewport for infinite scroll — it can never be 100% visible, so the callback never fires. Usethreshold: 0with a small sentinel element, orrootMargin. - Observing with
subtree: trueacross a huge tree and reacting to every mutation — batch and filter inside the callback (e.g., checkmutation.typeandmutation.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, useunobserve(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
isIntersectingflag and optionalintersectionRatiofor progressive effects.
Q: What do root, rootMargin, and threshold do in IntersectionObserver?
rootis the container considered the "viewport" for intersection (null = the actual viewport).rootMargingrows or shrinks the root's bounding box using CSS margin syntax — useful for firing "early".thresholdis 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: trueplusattributeFilter: ["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.