Skip to content

ResizeObserver & IntersectionObserver

advanced15 min read

Why Polling Dimensions Is Bad

Before we had these APIs, detecting element size changes was painful. You basically had to poll:

// ❌ Polling — runs every 100ms, wastes CPU, misses fast changes
setInterval(() => {
  const width = element.offsetWidth;
  if (width !== lastWidth) {
    handleResize(width);
    lastWidth = width;
  }
}, 100);

This approach is terrible in every way:

  1. Wasted CPU: Runs even when nothing changes (99% of the time)
  2. Forced layout: Each offsetWidth read forces synchronous layout if styles are dirty
  3. Missed changes: A 100ms interval misses sub-100ms size changes. Reduce the interval and CPU waste increases.

And for visibility detection? The scroll listener approach was just as bad:

// ❌ Scroll listener — fires hundreds of times during a scroll
window.addEventListener('scroll', () => {
  const rect = element.getBoundingClientRect(); // Forced layout
  if (rect.top < window.innerHeight) {
    loadContent();
  }
});

getBoundingClientRect forces layout on every scroll event. At 60fps scrolling, that is 60 forced layouts per second on the main thread.

Mental Model

Observers invert the control flow. Instead of you asking the browser "has anything changed?" on a loop, the browser tells you when something changes. The browser already tracks layout and visibility internally — observers let you hook into that existing work. Zero overhead when nothing changes, precise notifications when something does.

ResizeObserver

Basic Usage

const observer = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const { width, height } = entry.contentBoxSize[0];
    console.log(`Element resized: ${width}×${height}`);
  }
});

observer.observe(element);

// Stop observing
observer.unobserve(element);

// Stop all observations
observer.disconnect();

Entry Object

Each ResizeObserverEntry provides three box measurements:

const observer = new ResizeObserver((entries) => {
  for (const entry of entries) {
    // Content box: excludes padding and border
    const contentWidth = entry.contentBoxSize[0].inlineSize;
    const contentHeight = entry.contentBoxSize[0].blockSize;

    // Border box: includes padding and border
    const borderWidth = entry.borderBoxSize[0].inlineSize;
    const borderHeight = entry.borderBoxSize[0].blockSize;

    // Device pixel content box: physical pixels (for canvas rendering)
    const pixelWidth = entry.devicePixelContentBoxSize?.[0].inlineSize;
    const pixelHeight = entry.devicePixelContentBoxSize?.[0].blockSize;

    // Legacy (avoid — does not handle writing modes correctly)
    const rect = entry.contentRect; // { x, y, width, height }
  }
});
Quiz
Why does ResizeObserverEntry use inlineSize/blockSize instead of width/height?

Use Cases

Responsive Components (Container Queries Alternative)

const observer = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const width = entry.borderBoxSize[0].inlineSize;

    if (width < 400) {
      entry.target.classList.add('compact');
      entry.target.classList.remove('wide');
    } else {
      entry.target.classList.add('wide');
      entry.target.classList.remove('compact');
    }
  }
});

document.querySelectorAll('.card').forEach(card => observer.observe(card));

Canvas Pixel-Perfect Rendering

const observer = new ResizeObserver((entries) => {
  for (const entry of entries) {
    // Use device pixels for sharp canvas rendering
    const dpr = window.devicePixelRatio;
    const width = entry.devicePixelContentBoxSize?.[0].inlineSize
      ?? entry.contentBoxSize[0].inlineSize * dpr;
    const height = entry.devicePixelContentBoxSize?.[0].blockSize
      ?? entry.contentBoxSize[0].blockSize * dpr;

    canvas.width = width;
    canvas.height = height;
    redrawCanvas();
  }
});
observer.observe(canvas);

IntersectionObserver

Basic Usage

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      console.log('Element is visible');
      loadImage(entry.target);
      observer.unobserve(entry.target); // Stop observing after load
    }
  }
});

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

Options: threshold and rootMargin

const observer = new IntersectionObserver(callback, {
  // root: null = viewport (default). Can be any scrollable ancestor.
  root: null,

  // rootMargin: extends or shrinks the intersection area
  // Positive = trigger before element enters viewport
  rootMargin: '200px 0px', // Start loading 200px before visible

  // threshold: at what visibility ratio to trigger
  threshold: 0,       // Trigger when any pixel is visible (default)
  // threshold: 0.5,   // Trigger when 50% visible
  // threshold: 1.0,   // Trigger when 100% visible
  // threshold: [0, 0.25, 0.5, 0.75, 1], // Trigger at multiple ratios
});
Execution Trace
Configure
rootMargin: '200px', threshold: 0
The intersection area extends 200px beyond the viewport in all directions
Scroll
Element is 200px below viewport bottom
Element enters the extended intersection area. Callback fires with isIntersecting: true
Load
Image starts loading while still off-screen
By the time the user scrolls 200px more, the image is loaded. No visible loading delay.
Visible
Element enters the actual viewport
Image is already loaded and rendered. Seamless experience.
Quiz
IntersectionObserver has rootMargin: '100px' and threshold: 0.5. When does the callback fire?

Use Cases

Lazy Loading Images

const lazyObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      img.removeAttribute('data-src');
      lazyObserver.unobserve(img);
    }
  });
}, { rootMargin: '300px 0px' });

Infinite Scroll

const sentinel = document.querySelector('.load-more-sentinel');

const scrollObserver = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    loadNextPage();
  }
}, { rootMargin: '500px 0px' });

scrollObserver.observe(sentinel);

Visibility Tracking (Analytics)

const trackingObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
      trackImpression(entry.target.dataset.itemId);
      trackingObserver.unobserve(entry.target);
    }
  });
}, { threshold: 0.5 });

Scroll-Triggered Animations

const animObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    entry.target.classList.toggle('animate-in', entry.isIntersecting);
  });
}, { threshold: 0.15 });

Performance Characteristics

You might be wondering: are these observers actually efficient, or are they just a nicer API over the same expensive work? The answer is they are genuinely faster:

  • Batch delivery: Callbacks fire once per frame with all changed entries, not once per element
  • Off main thread (partially): IntersectionObserver's intersection calculations happen during compositing, not in JavaScript
  • No forced layout: Observers use the browser's internal layout data — no getBoundingClientRect calls
Callback timing

ResizeObserver callbacks fire after layout but before paint (between layout and paint in the rendering pipeline). IntersectionObserver callbacks are queued during the rendering steps and delivered as tasks — not microtasks — after the intersection data is computed. This means ResizeObserver mutations can be reflected in the same frame (no flash), while IntersectionObserver reactions typically appear in the next frame.

Avoiding Observation Loops

This is the one gotcha that bites everyone at least once. ResizeObserver has a specific pitfall: if your callback changes the observed element's size, you create an infinite loop:

// ❌ INFINITE LOOP — callback changes size, triggering another callback
const observer = new ResizeObserver((entries) => {
  for (const entry of entries) {
    entry.target.style.height = (entry.contentBoxSize[0].blockSize + 10) + 'px';
    // This size change triggers the observer again → infinite loop
  }
});

The browser is smart enough to not actually freeze. It prevents true infinite loops with a depth limit. After the first observation, if the callback causes new size changes, the browser fires the observer again. This repeats up to a depth limit (typically 4-8). After that, it reports an error via ResizeObserver loop completed with undelivered notifications and stops.

// ✅ SAFE — change a different element, not the observed one
const observer = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const width = entry.borderBoxSize[0].inlineSize;
    otherElement.style.width = width + 'px'; // Different element — no loop
  }
});
Quiz
Your ResizeObserver callback sets the observed element's height based on its width. The console shows 'ResizeObserver loop completed with undelivered notifications.' What happened?

React Integration Patterns

function useResizeObserver<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [size, setSize] = useState({ width: 0, height: 0 });

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

    const observer = new ResizeObserver((entries) => {
      const entry = entries[0];
      setSize({
        width: entry.borderBoxSize[0].inlineSize,
        height: entry.borderBoxSize[0].blockSize,
      });
    });

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

  return { ref, ...size };
}

// Usage
function ResponsiveCard() {
  const { ref, width } = useResizeObserver<HTMLDivElement>();
  return (
    <div ref={ref} className={width < 400 ? 'compact' : 'wide'}>
      {/* content */}
    </div>
  );
}
Key Rules
  1. 1Never poll dimensions with setInterval — use ResizeObserver. Never use scroll listeners for visibility — use IntersectionObserver.
  2. 2ResizeObserver provides contentBoxSize, borderBoxSize, and devicePixelContentBoxSize. Use logical properties (inlineSize/blockSize) for writing mode correctness.
  3. 3IntersectionObserver's rootMargin creates a buffer zone — use 200-500px to start loading/rendering before elements are visible.
  4. 4Callbacks are batched per frame. One callback fires with all changed entries, not one callback per element.
  5. 5ResizeObserver callbacks fire between layout and paint (same-frame mutations possible). IntersectionObserver callbacks fire after rendering (next-frame reactions).
  6. 6Never change an observed element's size inside a ResizeObserver callback — it creates a feedback loop that hits the browser's depth limit.
  7. 7Both observers use internal browser data, avoiding forced layout. They are fundamentally cheaper than manual dimension reading.
Interview Question

Q: Implement lazy-loading images that start loading 300px before they enter the viewport, track when they are 50% visible for analytics, and clean up properly.

A strong answer uses two IntersectionObservers: one with rootMargin: '300px' and threshold: 0 for loading (unobserve after load), another with threshold: 0.5 for visibility tracking (unobserve after tracking). Mentions that combining both thresholds in one observer is possible but less clear. Discusses cleanup: disconnect observers on component unmount, handle dynamically added images. Bonus: mention native lazy loading (loading="lazy") as a simpler alternative when the default browser heuristics are acceptable, and fetchpriority for above-fold images.