DNA🌐 Web BrowserBrowser Observer APIs
ðŸĢHatchlingBrowserAPIsPerformance

Browser Observer APIs

IntersectionObserver, MutationObserver, ResizeObserver, PerformanceObserver — four APIs that replace polling with efficient, browser-native observation patterns.

Browser Observer APIs

The browser provides four observer APIs that replace expensive polling and manual checking with efficient, callback-based observation. Senior engineers use these instead of scroll handlers, timers, and manual DOM scanning.

The Observer Pattern in the Browser

All four observers share the same pattern:

const observer = new SomeObserver(callback, options);
observer.observe(target);
// ... later
observer.unobserve(target);
observer.disconnect();
ObserverWatchesReplaces
IntersectionObserverElement visibility in viewportScroll handlers + getBoundingClientRect
MutationObserverDOM tree changesPolling, deprecated Mutation Events
ResizeObserverElement size changesWindow resize handler + polling
PerformanceObserverPerformance entriesManual performance.getEntries() polling

IntersectionObserver

Detects when an element enters or exits the viewport (or a parent container). This is the go-to API for lazy loading, infinite scroll, analytics tracking, and scroll-triggered animations.

Basic Usage

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        console.log(`${entry.target.id} is visible`);
        console.log(`Visible ratio: ${entry.intersectionRatio}`);
      }
    });
  },
  {
    root: null,            // null = viewport; or a scrollable parent
    rootMargin: '0px',     // Margin around root (can trigger before visible)
    threshold: [0, 0.25, 0.5, 0.75, 1.0]  // Fire at these visibility ratios
  }
);
 
observer.observe(document.getElementById('target'));

Implementation: Lazy Loading Images

function lazyLoadImages() {
  const images = document.querySelectorAll('img[data-src]');
 
  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const img = entry.target;
          img.src = img.dataset.src;
          img.removeAttribute('data-src');
          observer.unobserve(img);
        }
      });
    },
    { rootMargin: '200px' }  // Start loading 200px before visible
  );
 
  images.forEach(img => observer.observe(img));
}

Implementation: Infinite Scroll

function setupInfiniteScroll(sentinel, loadMore) {
  const observer = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting) {
        loadMore();
      }
    },
    { rootMargin: '400px' }  // Trigger before user reaches the end
  );
 
  observer.observe(sentinel);  // sentinel = empty div at bottom of list
  return () => observer.disconnect();
}

Implementation: Scroll-Triggered Animations

function animateOnScroll() {
  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          entry.target.classList.add('animate-in');
          observer.unobserve(entry.target);
        }
      });
    },
    { threshold: 0.2 }
  );
 
  document.querySelectorAll('.animate-on-scroll').forEach(el => {
    observer.observe(el);
  });
}

Implementation: Analytics — Section View Tracking

function trackSectionVisibility() {
  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
          analytics.track('section_viewed', {
            section: entry.target.dataset.section,
            timestamp: Date.now(),
          });
          observer.unobserve(entry.target);
        }
      });
    },
    { threshold: 0.5 }  // Must be 50% visible to count
  );
 
  document.querySelectorAll('[data-section]').forEach(el => {
    observer.observe(el);
  });
}

React Hook: useIntersectionObserver

function useIntersectionObserver(
  options: IntersectionObserverInit = {}
): [RefCallback<Element>, boolean] {
  const [isIntersecting, setIsIntersecting] = useState(false);
  const observerRef = useRef<IntersectionObserver | null>(null);
 
  const ref = useCallback((node: Element | null) => {
    if (observerRef.current) {
      observerRef.current.disconnect();
    }
 
    if (!node) return;
 
    observerRef.current = new IntersectionObserver(
      ([entry]) => setIsIntersecting(entry.isIntersecting),
      options
    );
 
    observerRef.current.observe(node);
  }, [options.root, options.rootMargin, options.threshold]);
 
  return [ref, isIntersecting];
}
 
// Usage
function LazyComponent() {
  const [ref, isVisible] = useIntersectionObserver({ rootMargin: '200px' });
 
  return (
    <div ref={ref}>
      {isVisible ? <ExpensiveContent /> : <Placeholder />}
    </div>
  );
}

Why Not Scroll Handlers?

// ❌ Scroll handler — fires 60+ times per second, calls getBoundingClientRect()
window.addEventListener('scroll', () => {
  const rect = element.getBoundingClientRect(); // Forces layout!
  if (rect.top < window.innerHeight) {
    loadImage();
  }
});
 
// ✅ IntersectionObserver — browser-optimized, no forced layout, no scroll jank
const observer = new IntersectionObserver(callback);
observer.observe(element);

IntersectionObserver runs on a separate thread from main JavaScript execution. It doesn't cause layout thrashing and doesn't fire on every scroll pixel.

MutationObserver

Watches for changes to the DOM tree — element additions, removals, attribute changes, text changes:

const observer = 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(`${mutation.attributeName} changed on`, mutation.target);
        break;
      case 'characterData':
        console.log('Text changed:', mutation.target.textContent);
        break;
    }
  });
});
 
observer.observe(document.body, {
  childList: true,       // Watch child additions/removals
  attributes: true,      // Watch attribute changes
  characterData: true,   // Watch text content changes
  subtree: true,         // Watch entire subtree, not just direct children
  attributeFilter: ['class', 'data-state'],  // Only these attributes
  attributeOldValue: true,  // Include previous attribute value
});

Use Cases

// Auto-initialize third-party widgets when added to DOM
const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node instanceof HTMLElement) {
        node.querySelectorAll('[data-widget]').forEach(initWidget);
      }
    }
  }
});
 
// Detect DOM changes from browser extensions (security)
const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.type === 'childList') {
      for (const node of mutation.addedNodes) {
        if (isInjectedScript(node)) {
          node.remove();
          reportSecurityEvent();
        }
      }
    }
  }
});

ResizeObserver

Watches for size changes on individual elements — not just the window:

const observer = new ResizeObserver((entries) => {
  entries.forEach(entry => {
    const { width, height } = entry.contentRect;
    console.log(`${entry.target.id}: ${width}x${height}`);
 
    // contentBoxSize gives more precise info
    const [box] = entry.contentBoxSize;
    console.log(`Inline: ${box.inlineSize}, Block: ${box.blockSize}`);
  });
});
 
observer.observe(document.getElementById('container'));

Use Cases

// Responsive component behavior (not just CSS media queries)
const observer = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const width = entry.contentRect.width;
    if (width < 400) {
      entry.target.classList.add('compact');
    } else {
      entry.target.classList.remove('compact');
    }
  }
});
 
// Dynamic chart resizing
const observer = new ResizeObserver((entries) => {
  for (const entry of entries) {
    chart.resize(entry.contentRect.width, entry.contentRect.height);
  }
});
observer.observe(chartContainer);

React Hook: useElementSize

function useElementSize(): [RefCallback<Element>, { width: number; height: number }] {
  const [size, setSize] = useState({ width: 0, height: 0 });
  const observerRef = useRef<ResizeObserver | null>(null);
 
  const ref = useCallback((node: Element | null) => {
    if (observerRef.current) observerRef.current.disconnect();
    if (!node) return;
 
    observerRef.current = new ResizeObserver(([entry]) => {
      setSize({
        width: entry.contentRect.width,
        height: entry.contentRect.height,
      });
    });
 
    observerRef.current.observe(node);
  }, []);
 
  return [ref, size];
}

Why Not Window Resize?

window.addEventListener('resize') only fires when the window resizes. Container queries and CSS changes can resize elements without the window changing. ResizeObserver catches all of them.

PerformanceObserver

Watches for performance entries in real time — replacing manual performance.getEntries() polling:

// Monitor Largest Contentful Paint
const lcpObserver = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
  console.log('LCP:', lastEntry.startTime, lastEntry.element);
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
 
// Monitor Long Tasks (>50ms)
const taskObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 100) {
      console.warn(`Long task: ${entry.duration}ms`, entry);
    }
  }
});
taskObserver.observe({ entryTypes: ['longtask'] });
 
// Monitor Resource Loading
const resourceObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 1000) {
      console.warn(`Slow resource: ${entry.name} took ${entry.duration}ms`);
    }
  }
});
resourceObserver.observe({ type: 'resource', buffered: true });

Real-World: Web Vitals Monitoring

function monitorWebVitals(callback) {
  // FCP
  new PerformanceObserver((list) => {
    const fcp = list.getEntries().find(e => e.name === 'first-contentful-paint');
    if (fcp) callback({ metric: 'FCP', value: fcp.startTime });
  }).observe({ type: 'paint', buffered: true });
 
  // LCP
  new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lcp = entries[entries.length - 1];
    callback({ metric: 'LCP', value: lcp.startTime });
  }).observe({ type: 'largest-contentful-paint', buffered: true });
 
  // CLS
  let clsValue = 0;
  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (!entry.hadRecentInput) clsValue += entry.value;
    }
    callback({ metric: 'CLS', value: clsValue });
  }).observe({ type: 'layout-shift', buffered: true });
}

Cleanup Patterns

Always disconnect observers to prevent memory leaks:

// Vanilla JS
const observer = new IntersectionObserver(callback);
observer.observe(element);
// ... later
observer.disconnect();
 
// React useEffect
useEffect(() => {
  const observer = new IntersectionObserver(callback, options);
  if (ref.current) observer.observe(ref.current);
  return () => observer.disconnect();
}, []);

Interview Signal

Senior candidates demonstrate:

  1. API knowledge — All four observers and their specific use cases
  2. Performance reasoning — Why observers beat scroll handlers and polling
  3. Implementation skill — Lazy loading, infinite scroll, resize-aware components
  4. React integration — Custom hooks with proper cleanup
  5. Production patterns — Web Vitals monitoring, security detection, analytics tracking