Articles

ResizeObserver vs IntersectionObserver: Which to Use

ResizeObserver watches an element's size; IntersectionObserver watches its visibility. Both replace scroll/resize listeners — here's when to use each.

Takina Takina · · 5 min read
Abstract frontend interface illustration

ResizeObserver and IntersectionObserver are both browser APIs for reacting to layout changes without polling, but they watch different things: ResizeObserver fires when an element’s size changes, and IntersectionObserver fires when an element’s visibility relative to a viewport or ancestor changes. Confusing them is a common source of over-engineered code — one problem solved with the wrong tool.

Why not just use scroll and resize listeners

Before both APIs existed, developers reached for window.addEventListener("resize", ...) and window.addEventListener("scroll", ...), then manually called getBoundingClientRect() inside the handler to figure out what changed. This works, but it has real costs:

  • resize only fires for the window, not for individual elements. Watching a specific div’s size (say, one resized by a CSS resize: both handle, or a flex item that shrinks) required a ResizeObserver-shaped hack — recalculating on every window resize and hoping nothing else changed the element’s size.
  • scroll fires continuously and synchronously, on the main thread, for every pixel scrolled. Reading layout (getBoundingClientRect) inside a scroll handler forces a synchronous reflow — a well-known performance trap. You had to hand-roll debouncing or throttling just to keep the page responsive.

Both new APIs move the work off the critical path: the browser batches the checks and calls your callback asynchronously, typically once per frame, without forcing a layout read in your code.

ResizeObserver

ResizeObserver watches one or more elements and fires a callback whenever their content-box or border-box size changes — from a CSS change, a font load, a flex/grid reflow, or a user dragging a manual resize handle.

const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const { width, height } = entry.contentRect;
    console.log(`New size: ${width}x${height}`);
  }
});

ro.observe(document.querySelector(".chart-container"));

Typical uses: redrawing a <canvas>-based chart when its container resizes, recalculating virtualized list item heights, or implementing container-query-like behavior in JavaScript for logic that CSS container queries alone can’t express (like re-rendering a chart’s internal SVG viewBox).

IntersectionObserver

IntersectionObserver watches whether a target element overlaps a “root” — by default, the viewport — and fires when that overlap crosses a threshold you specify.

const io = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      entry.target.classList.add("visible");
    }
  }
}, { threshold: 0.1 });

document.querySelectorAll(".lazy-section").forEach((el) => io.observe(el));

Typical uses: lazy-loading images and video as they scroll into view, infinite-scroll pagination (observe a sentinel element at the bottom of a list), triggering scroll-based animations, and pausing expensive work (video playback, canvas animation) for elements that have scrolled off-screen.

Side by side

ResizeObserverIntersectionObserver
WatchesAn element’s sizeAn element’s visibility vs. a root
Fires onContent-box or border-box resizeCrossing a visibility threshold
Typical rootN/A — always the element itselfViewport, or a specified scroll container
Common useResponsive charts, virtualized lists, container-query polyfillsLazy loading, infinite scroll, scroll animations
Replacesresize events + manual size checksscroll events + getBoundingClientRect() polling
Callback timingAsync, batched by the browserAsync, batched by the browser

Options that shape behavior

Both observers accept configuration beyond just the target element:

  • ResizeObserver.observe(el, { box: "border-box" }) — by default, ResizeObserver reports the content box (excluding padding and border). Passing box: "border-box" reports the full box including padding and border, which matters if your layout logic cares about the element’s actual footprint rather than just its inner content area.
  • IntersectionObserver accepts root (the scroll container to measure against, instead of the viewport), rootMargin (a CSS-margin-like string that expands or shrinks the root’s effective bounding box, commonly used to start lazy-loading before an element is actually visible), and threshold (one or more visibility ratios, from 0 to 1, at which the callback should fire).

A common pattern combining rootMargin with lazy loading: setting rootMargin: "200px" triggers the callback when an image is still 200 pixels below the viewport, so the image has time to start loading before the user actually scrolls to it — avoiding the visible pop-in of a naive “load only once fully visible” implementation.

Cleaning up observers

Both APIs are opt-in and need explicit teardown — an observer left running after a component unmounts keeps holding a reference to its target elements and continues firing callbacks against DOM nodes that may no longer be in use. Call .unobserve(element) to stop watching a single target, or .disconnect() to stop the observer entirely:

useEffect(() => {
  const ro = new ResizeObserver(handleResize);
  ro.observe(containerRef.current);
  return () => ro.disconnect();
}, []);

This mirrors the cleanup discipline required for any subscription-based browser API — the same reasoning that applies to removing manually attached event listeners, or clearing an interval set with setInterval.

Using them together

The two are complementary, not competing, and real UI often needs both. A virtualized list is a good example: IntersectionObserver tells you when a row scrolls into the visible range (so you can render it), while ResizeObserver tells you if that row’s height changed after rendering (so you can update the list’s total scroll height, since virtualized lists commonly deal with variable-height content).

Neither API should be reached for by default, though — if you only need to know whether an element is currently on screen once, at mount, getBoundingClientRect() in an effect is simpler and sufficient. Reach for the observers when you need to react to changes over time without re-running expensive layout reads yourself.

The takeaway

ResizeObserver answers “how big is this element now?” and IntersectionObserver answers “is this element visible now?” Both replace older patterns built on scroll and resize listeners with an async, batched callback that avoids forcing synchronous layout on the main thread. Pick based on what you’re actually tracking — size or visibility — and don’t be afraid to use both in the same component when a feature genuinely needs to know both.

Takina Takina · · 3 min read

What Is a SharedWorker? Explained

A SharedWorker runs one script shared across every tab and window of the same origin — a single background thread they can all connect to and message.

#JavaScript #Web Development #Performance
Takina Takina · · 4 min read

setTimeout vs setInterval in JavaScript

setTimeout runs a callback once after a delay; setInterval repeats it on a fixed cadence — but drifts under load in ways setTimeout recursion avoids.

#JavaScript #Web Development #Performance
Takina Takina · · 4 min read

How Garbage Collection Works in JavaScript

JavaScript frees memory automatically by tracking reachability, not reference counts. How the mark-and-sweep algorithm works and what causes leaks anyway.

#JavaScript #Web Development #Performance