Skip to content
JavaScript js browser 5 min read

Intersection & Resize Observers

Detecting when an element scrolls into view or changes size used to mean attaching scroll and resize listeners and recalculating geometry on every event. That approach is slow, jittery, and forces expensive layout reflows on the main thread. The observer APIs flip the model: instead of polling, the browser notifies you asynchronously when something actually changes. IntersectionObserver watches visibility relative to a viewport or container, and ResizeObserver watches an element’s content box. Both run off the critical path and batch their work, making them dramatically faster for lazy loading, infinite scroll, reveal animations, and responsive components.

Why observers beat scroll and resize listeners

A scroll handler can fire dozens of times per second, and reading getBoundingClientRect() inside it triggers synchronous layout. Even with throttling you are guessing at frame timing and still doing math on the main thread. Observers eliminate the guesswork: the browser computes intersections and size changes internally and hands you the results in a single batched callback.

Concernscroll / resize listenerObserver API
When it runsEvery event, very frequentlyOnly when the watched value changes
Layout costManual getBoundingClientRect() reads (reflow)Computed by the browser, no forced reflow
ThrottlingYou must add it yourselfBuilt-in batching
Threshold controlManual comparisonDeclarative threshold / rootMargin
Per-element trackingHand-rolled bookkeepingOne observer handles many targets

IntersectionObserver basics

You create an observer with a callback and an options object, then call observe() on each element you care about. The callback receives an array of IntersectionObserverEntry objects describing which targets changed.

const observer = new IntersectionObserver(
  (entries, observer) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        console.log(`${entry.target.id} is now visible`);
        console.log(`Visible ratio: ${entry.intersectionRatio.toFixed(2)}`);
      }
    }
  },
  {
    root: null,          // null = the browser viewport
    rootMargin: "0px",   // grow/shrink the root's bounding box
    threshold: 0.5,      // fire when 50% of the target is visible
  }
);

const card = document.querySelector("#card");
observer.observe(card);

Output:

card is now visible
Visible ratio: 0.51

The most useful options:

OptionTypeMeaning
rootElement | nullThe scrollable ancestor used as the viewport. null means the document viewport.
rootMarginstringCSS-like margin (e.g. "200px 0px") that expands or contracts the root box. Use it to trigger early.
thresholdnumber | number[]A ratio (0–1) or list of ratios at which the callback fires.

Tip: pass threshold: [0, 0.25, 0.5, 0.75, 1] when you need progressive feedback (such as a scroll-driven progress indicator). The callback fires each time the target crosses one of those ratios.

Lazy loading and reveal-on-scroll

A classic use is loading images only as they approach the viewport. A positive rootMargin makes them start loading slightly before they appear, so the swap is invisible to the user. Once an element has done its job, call unobserve() to stop tracking it.

<div style="font-family: system-ui; padding: 1rem">
  <p>Scroll down to reveal the cards.</p>
  <div style="height: 90vh"></div>
  <div class="card">One</div>
  <div class="card">Two</div>
  <div class="card">Three</div>

  <style>
    .card {
      margin: 2rem 0;
      padding: 3rem;
      background: #4f46e5;
      color: white;
      border-radius: 12px;
      font-size: 1.5rem;
      opacity: 0;
      transform: translateY(40px);
      transition: opacity 0.6s ease, transform 0.6s ease;
    }
    .card.visible { opacity: 1; transform: none; }
  </style>

  <script>
    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            entry.target.classList.add("visible");
            observer.unobserve(entry.target); // reveal once, then stop
          }
        }
      },
      { threshold: 0.25, rootMargin: "0px 0px -10% 0px" }
    );

    document.querySelectorAll(".card").forEach((el) => observer.observe(el));
  </script>
</div>

Infinite scroll

For infinite scroll, observe a sentinel element placed at the bottom of the list. When it enters view, fetch the next page and append more items. Because the sentinel keeps moving down as content grows, a single observer powers the whole feed.

const sentinel = document.querySelector("#sentinel");
let page = 1;

const loader = new IntersectionObserver(async (entries) => {
  if (!entries[0].isIntersecting) return;

  const res = await fetch(`/api/items?page=${page}`);
  const items = await res.json();

  for (const item of items) {
    const li = document.createElement("li");
    li.textContent = item.title;
    document.querySelector("#list").append(li);
  }
  page += 1;
}, { rootMargin: "300px" }); // start loading before the user hits the end

loader.observe(sentinel);

ResizeObserver

ResizeObserver reports changes to an element’s size — not just when the window resizes, but whenever layout, content, or CSS changes its box. This is the foundation of true container queries in JavaScript: a component can react to its own width regardless of the viewport.

const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const { width } = entry.contentRect;
    entry.target.dataset.size = width < 400 ? "compact" : "wide";
    console.log(`Width is now ${Math.round(width)}px`);
  }
});

ro.observe(document.querySelector("#panel"));

Output:

Width is now 320px
Width is now 540px

Each entry exposes contentRect (a DOMRectReadOnly) plus borderBoxSize and contentBoxSize arrays for precise box-model data. Here is an interactive demo — drag the resizable box and watch the label update without any resize listener.

<div style="font-family: system-ui; padding: 1rem">
  <p>Drag the bottom-right corner of the box.</p>
  <div id="panel" style="
    resize: both; overflow: auto; min-width: 150px; min-height: 100px;
    width: 300px; padding: 1rem; border: 2px dashed #4f46e5; border-radius: 8px;">
    <strong id="label">Resize me</strong>
  </div>

  <script>
    const label = document.getElementById("label");
    const ro = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const { width, height } = entry.contentRect;
        const mode = width < 250 ? "compact" : "wide";
        label.textContent = `${Math.round(width)} x ${Math.round(height)} (${mode})`;
      }
    });
    ro.observe(document.getElementById("panel"));
  </script>
</div>

Warning: avoid changing the observed element’s size from inside the ResizeObserver callback. Doing so can trigger a resize loop; the browser will log a “ResizeObserver loop completed with undelivered notifications” error and skip frames. Write to a different element, or guard the change behind a condition.

Cleaning up

Both observers expose unobserve(target) to stop watching one element and disconnect() to stop everything. Always disconnect when a component unmounts to prevent leaks and stray callbacks against detached nodes.

// In a component teardown / unmount hook
observer.disconnect();

Best Practices

  • Reuse a single observer for many targets instead of creating one per element — it is cheaper and simpler to manage.
  • Use rootMargin to trigger work slightly ahead of time (lazy loading, prefetching) for a seamless feel.
  • Call unobserve() for one-shot effects like reveal animations so the callback stops firing once done.
  • Always disconnect() on teardown to avoid memory leaks and callbacks on removed nodes.
  • Prefer ResizeObserver over the window resize event for component-level layout — it catches size changes the window event never sees.
  • Never mutate the observed element’s size inside its own ResizeObserver callback to avoid resize loops.
  • Treat callbacks as asynchronous and batched: read entry data fresh each time rather than caching stale geometry.
Last updated June 1, 2026
Was this helpful?