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.
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:
resizeonly fires for the window, not for individual elements. Watching a specificdiv’s size (say, one resized by a CSSresize: bothhandle, or a flex item that shrinks) required aResizeObserver-shaped hack — recalculating on every window resize and hoping nothing else changed the element’s size.scrollfires 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
| ResizeObserver | IntersectionObserver | |
|---|---|---|
| Watches | An element’s size | An element’s visibility vs. a root |
| Fires on | Content-box or border-box resize | Crossing a visibility threshold |
| Typical root | N/A — always the element itself | Viewport, or a specified scroll container |
| Common use | Responsive charts, virtualized lists, container-query polyfills | Lazy loading, infinite scroll, scroll animations |
| Replaces | resize events + manual size checks | scroll events + getBoundingClientRect() polling |
| Callback timing | Async, batched by the browser | Async, 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,ResizeObserverreports the content box (excluding padding and border). Passingbox: "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.IntersectionObserveracceptsroot(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), andthreshold(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.
Keep reading
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.
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.
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.