Steven's Knowledge
Advanced Features

Observers

IntersectionObserver, MutationObserver, ResizeObserver, and PerformanceObserver — efficient, event-driven DOM and performance monitoring

Observers

Browser observer APIs provide an efficient, callback-based way to react to changes in the DOM, viewport, element sizes, and performance metrics — replacing costly polling and scroll-event patterns.

Problems Solved

ProblemTraditional ApproachObserver Solution
Detect element visibilityscroll event + getBoundingClientRect()IntersectionObserver
Watch DOM changesPolling or deprecated Mutation EventsMutationObserver
Respond to element resizeresize event (window only)ResizeObserver
Collect performance metricsManual timing with performance.now()PerformanceObserver

Observer Pattern Overview

All observers share the same pattern:

1. Create observer with callback
   const observer = new XxxObserver(callback);

2. Start observing targets
   observer.observe(element);

3. Callback fires with entries
   callback(entries, observer) { ... }

4. Stop observing
   observer.unobserve(element);  // one target
   observer.disconnect();         // all targets

IntersectionObserver

Detects when an element enters or exits the viewport (or a specified container).

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 container
  rootMargin: '0px',    // margin around root (e.g., '100px' to trigger early)
  threshold: [0, 0.5, 1] // fire at 0%, 50%, 100% visibility
});

observer.observe(document.querySelector('#target'));

Entry Properties

PropertyTypeDescription
isIntersectingbooleanWhether element is within root
intersectionRationumber0-1, fraction of element visible
boundingClientRectDOMRectElement's bounding rectangle
intersectionRectDOMRectVisible portion of element
rootBoundsDOMRectRoot element's bounding rectangle
targetElementThe observed element
timenumberTimestamp of intersection change

Lazy Loading Images

function lazyLoadImages() {
  const observer = new IntersectionObserver((entries, obs) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        if (img.dataset.srcset) img.srcset = img.dataset.srcset;
        img.classList.add('loaded');
        obs.unobserve(img); // Stop observing once loaded
      }
    });
  }, {
    rootMargin: '200px 0px' // Start loading 200px before viewport
  });

  document.querySelectorAll('img[data-src]').forEach(img => {
    observer.observe(img);
  });
}

Infinite Scroll

function infiniteScroll(container, loadMore) {
  // Observe a sentinel element at the bottom of the list
  const sentinel = document.createElement('div');
  sentinel.className = 'scroll-sentinel';
  container.appendChild(sentinel);

  let loading = false;

  const observer = new IntersectionObserver(async (entries) => {
    if (entries[0].isIntersecting && !loading) {
      loading = true;
      const hasMore = await loadMore();
      loading = false;

      if (!hasMore) {
        observer.disconnect();
        sentinel.remove();
      }
    }
  }, {
    root: container, // or null for viewport
    rootMargin: '100px'
  });

  observer.observe(sentinel);
  return () => observer.disconnect();
}

// Usage
infiniteScroll(
  document.querySelector('.list'),
  async () => {
    const items = await fetchNextPage();
    appendItems(items);
    return items.length > 0; // false stops the observer
  }
);

Scroll-Linked Animations (Fade In)

function fadeInOnScroll(selector) {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        entry.target.classList.add('visible');
        observer.unobserve(entry.target);
      }
    });
  }, { threshold: 0.1 });

  document.querySelectorAll(selector).forEach(el => observer.observe(el));
}
.fade-in { opacity: 0; transform: translateY(20px); transition: all 0.6s ease; }
.fade-in.visible { opacity: 1; transform: translateY(0); }

Analytics: Viewport Tracking

function trackImpressions(elements, onImpression) {
  const seen = new WeakSet();

  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting && entry.intersectionRatio >= 0.5 && !seen.has(entry.target)) {
        seen.add(entry.target);
        onImpression(entry.target.dataset.trackingId);
      }
    });
  }, { threshold: 0.5 });

  elements.forEach(el => observer.observe(el));
  return () => observer.disconnect();
}

MutationObserver

Watches for changes in the DOM tree — attribute modifications, child additions/removals, text changes.

Basic Usage

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.data);
        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 all descendants
  attributeFilter: ['class', 'style'],   // Only these attributes
  attributeOldValue: true,               // Include previous value
  characterDataOldValue: true,           // Include previous text
});

Configuration Options

OptionTypeDescription
childListbooleanWatch for added/removed child nodes
attributesbooleanWatch attribute changes
characterDatabooleanWatch text node changes
subtreebooleanExtend observation to all descendants
attributeFilterstring[]Only watch specific attributes
attributeOldValuebooleanRecord previous attribute value
characterDataOldValuebooleanRecord previous text value

Auto-Initialize Components on DOM Insert

// Automatically initialize components when they're added to the DOM
function autoInit(selector, initFn) {
  // Initialize existing elements
  document.querySelectorAll(selector).forEach(initFn);

  // Watch for new elements
  const observer = new MutationObserver((mutations) => {
    mutations.forEach(mutation => {
      mutation.addedNodes.forEach(node => {
        if (node.nodeType !== Node.ELEMENT_NODE) return;

        // Check the node itself
        if (node.matches(selector)) initFn(node);

        // Check descendants
        node.querySelectorAll?.(selector).forEach(initFn);
      });
    });
  });

  observer.observe(document.body, { childList: true, subtree: true });
  return () => observer.disconnect();
}

// Usage: auto-initialize all [data-tooltip] elements
autoInit('[data-tooltip]', (el) => {
  el.addEventListener('mouseenter', showTooltip);
  el.addEventListener('mouseleave', hideTooltip);
});

DOM Diff / Change Tracking

// Track all DOM changes for undo/redo
function trackChanges(root) {
  const history = [];

  const observer = new MutationObserver((mutations) => {
    history.push(mutations.map(m => ({
      type: m.type,
      target: m.target,
      addedNodes: [...m.addedNodes],
      removedNodes: [...m.removedNodes],
      attributeName: m.attributeName,
      oldValue: m.oldValue,
    })));
  });

  observer.observe(root, {
    childList: true,
    attributes: true,
    characterData: true,
    subtree: true,
    attributeOldValue: true,
    characterDataOldValue: true,
  });

  return {
    getHistory: () => [...history],
    disconnect: () => observer.disconnect(),
  };
}

ResizeObserver

Monitors changes to an element's content box or border box dimensions.

Basic Usage

const observer = new ResizeObserver((entries) => {
  entries.forEach(entry => {
    const { width, height } = entry.contentRect;
    console.log(`${entry.target.id}: ${width} x ${height}`);

    // Also available:
    // entry.borderBoxSize[0].inlineSize / blockSize
    // entry.contentBoxSize[0].inlineSize / blockSize
  });
});

observer.observe(document.querySelector('#container'));

Responsive Components (Container Queries Alternative)

function responsiveComponent(element) {
  const observer = new ResizeObserver((entries) => {
    const { inlineSize: width } = entries[0].contentBoxSize[0];

    element.classList.remove('compact', 'regular', 'wide');

    if (width < 400) {
      element.classList.add('compact');
    } else if (width < 800) {
      element.classList.add('regular');
    } else {
      element.classList.add('wide');
    }
  });

  observer.observe(element);
  return () => observer.disconnect();
}

Auto-Resize Canvas / Charts

function autoResizeCanvas(canvas) {
  const ctx = canvas.getContext('2d');

  const observer = new ResizeObserver((entries) => {
    const { width, height } = entries[0].contentRect;
    const dpr = window.devicePixelRatio || 1;

    canvas.width = width * dpr;
    canvas.height = height * dpr;
    ctx.scale(dpr, dpr);

    drawChart(ctx, width, height); // Re-draw at new size
  });

  observer.observe(canvas.parentElement);
  return () => observer.disconnect();
}

Text Truncation Detection

function watchTruncation(element, onTruncationChange) {
  let wasTruncated = false;

  const observer = new ResizeObserver(() => {
    const isTruncated = element.scrollWidth > element.clientWidth;

    if (isTruncated !== wasTruncated) {
      wasTruncated = isTruncated;
      onTruncationChange(isTruncated);
    }
  });

  observer.observe(element);
  return () => observer.disconnect();
}

// Show tooltip only when text is truncated
watchTruncation(nameElement, (isTruncated) => {
  nameElement.title = isTruncated ? nameElement.textContent : '';
});

PerformanceObserver

Provides access to performance timeline entries as they're recorded by the browser.

Basic Usage

const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach(entry => {
    console.log(`${entry.entryType}: ${entry.name} — ${entry.duration}ms`);
  });
});

observer.observe({
  entryTypes: ['paint', 'largest-contentful-paint', 'layout-shift', 'longtask']
});

Entry Types

Entry TypeWhat It MeasuresKey Properties
paintFP, FCPname, startTime
largest-contentful-paintLCPstartTime, size, element
layout-shiftCLSvalue, hadRecentInput
longtaskTasks > 50msduration, startTime
eventInput delay (INP)duration, processingStart
resourceResource loadingduration, transferSize
navigationPage load timingdomContentLoadedEventEnd
markCustom marksstartTime
measureCustom measuresduration

Core Web Vitals Monitoring

function observeWebVitals(callback) {
  // LCP
  new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lcp = entries[entries.length - 1]; // Last entry is final LCP
    callback('LCP', lcp.startTime);
  }).observe({ type: 'largest-contentful-paint', buffered: true });

  // CLS
  let clsValue = 0;
  new PerformanceObserver((list) => {
    list.getEntries().forEach(entry => {
      if (!entry.hadRecentInput) {
        clsValue += entry.value;
      }
    });
    callback('CLS', clsValue);
  }).observe({ type: 'layout-shift', buffered: true });

  // Long Tasks (affects INP/TBT)
  new PerformanceObserver((list) => {
    list.getEntries().forEach(entry => {
      if (entry.duration > 50) {
        callback('LongTask', entry.duration);
      }
    });
  }).observe({ type: 'longtask', buffered: true });
}

observeWebVitals((metric, value) => {
  console.log(`${metric}: ${value}`);
  // Send to analytics endpoint
});

Slow Resource Detection

new PerformanceObserver((list) => {
  list.getEntries().forEach(entry => {
    if (entry.duration > 1000) {
      console.warn(`Slow resource: ${entry.name} (${Math.round(entry.duration)}ms)`);
      console.warn(`  DNS: ${Math.round(entry.domainLookupEnd - entry.domainLookupStart)}ms`);
      console.warn(`  Connect: ${Math.round(entry.connectEnd - entry.connectStart)}ms`);
      console.warn(`  TTFB: ${Math.round(entry.responseStart - entry.requestStart)}ms`);
      console.warn(`  Download: ${Math.round(entry.responseEnd - entry.responseStart)}ms`);
    }
  });
}).observe({ type: 'resource', buffered: true });

Comparison

ObserverTargetFires WhenTypical Use
IntersectionObserverElement + viewport/containerVisibility changesLazy load, infinite scroll, analytics
MutationObserverDOM node + subtreeDOM mutatesAuto-init, change tracking, WYSIWYG
ResizeObserverElementDimensions changeResponsive components, canvas, charts
PerformanceObserverPerformance timelineEntries recordedWeb vitals, slow resource detection

Framework Integration

React Hook

function useIntersection(ref, options = {}) {
  const [entry, setEntry] = useState(null);

  useEffect(() => {
    if (!ref.current) return;

    const observer = new IntersectionObserver(
      ([entry]) => setEntry(entry),
      options
    );

    observer.observe(ref.current);
    return () => observer.disconnect();
  }, [ref, options.threshold, options.rootMargin]);

  return entry;
}

// Usage
function LazyImage({ src }) {
  const ref = useRef();
  const entry = useIntersection(ref, { rootMargin: '200px' });

  return (
    <img ref={ref} src={entry?.isIntersecting ? src : undefined} />
  );
}
function useResizeObserver(ref) {
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    if (!ref.current) return;

    const observer = new ResizeObserver(([entry]) => {
      setSize({
        width: entry.contentRect.width,
        height: entry.contentRect.height,
      });
    });

    observer.observe(ref.current);
    return () => observer.disconnect();
  }, [ref]);

  return size;
}

Best Practices

  • Always call disconnect() or unobserve() in cleanup (React useEffect return, disconnectedCallback, etc.)
  • Use rootMargin with IntersectionObserver to trigger actions before elements become visible
  • Avoid heavy computation inside observer callbacks — debounce or use requestAnimationFrame if needed
  • Use { buffered: true } with PerformanceObserver to get entries recorded before the observer was created
  • MutationObserver callbacks fire as microtasks — be careful about causing infinite loops by mutating observed nodes inside the callback

On this page