ResizeObserver vs Resize and Scroll Handlers

This guide is part of Layout Thrashing & Forced Reflow, within Rendering & CSS Performance. For years, responsive behaviour in JavaScript meant listening to window.resize and scroll events and measuring elements in the handler: is the sidebar narrower than 300px, is this section in view, how tall is the header now. These handlers fire very often — scroll events can fire on every frame, resize events many times per second while dragging — and each measurement may force a layout if anything changed since the last frame. The result is jank during scrolling and resizing and busy main threads that delay input.

ResizeObserver and IntersectionObserver replace most of these patterns. They deliver exactly the information needed (element sizes, visibility changes) at points in the rendering cycle where layout is already computed, only when something changed, and without per-event polling. They are also element-specific: a component can react to its own size rather than the window's.

Event handlers vs observers Comparison of resize and scroll event handlers with ResizeObserver and IntersectionObserver. Event handlers vs observers Aspect resize / scroll handlers Observers When they fire every event, many per second only on change Layout reads force layout if dirty sizes delivered after layout Granularity window-level per element Throttling needed yes built in

Rapid Diagnosis

  • Search the codebase for addEventListener('resize' and addEventListener('scroll' and list what each handler reads and writes.
  • Record a trace while scrolling and resizing; look for handler tasks with Layout events and forced reflow warnings.
  • Check for throttling and debouncing utilities wrapped around handlers — a sign that handlers were known to be expensive.
  • Check for non-passive touch and wheel listeners, which can delay scrolling itself.

Root Cause Analysis

1. High event frequency. Handlers run far more often than anything actually changes.

2. Measuring in handlers. Reads after other writes in the same frame force layout.

3. Window-level signals for element-level questions. Components care about their own size, not the window's.

4. Visibility by geometry. Computing "is it in view" with getBoundingClientRect() on scroll for many elements.

Step-by-Step Resolution

1. Replace resize-plus-measure with ResizeObserver

javascript
const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const width = entry.contentBoxSize[0].inlineSize;     // delivered, not read
    entry.target.classList.toggle('compact', width < 420);
  }
});
document.querySelectorAll('.card-list').forEach((el) => ro.observe(el));
// trade-off: toggling classes that change the observed element's size can cause
// a resize loop; the browser stops it with an error, so design for stable sizes.

2. Replace scroll-plus-geometry with IntersectionObserver

javascript
const io = new IntersectionObserver((entries) => {
  for (const e of entries) e.target.classList.toggle('in-view', e.isIntersecting);
}, { rootMargin: '0px 0px -20% 0px', threshold: [0, 0.5] });
document.querySelectorAll('section[data-track]').forEach((s) => io.observe(s));
// trade-off: callbacks are asynchronous and may lag a frame behind scrolling;
// use CSS scroll-driven animations for effects that must track scroll exactly.

3. Use CSS where possible

Container queries replace most "component width" scripts; position: sticky replaces header-fixing scripts; scroll-driven animations replace parallax and progress-bar scroll handlers.

4. Keep any remaining scroll handlers passive and cheap

If a scroll handler is unavoidable, register it as { passive: true }, read only scrollY (cheap when layout is clean), and defer writes to requestAnimationFrame.

Main-thread time during a 5-second scroll (article page) Bar chart comparing main-thread time spent in scroll-related work for handler-based and observer-based implementations. Main-thread time during a 5-second scroll (article page) Scroll handler + getBoundingClientRect x 40 1450ms Throttled handler (100ms) 420ms IntersectionObserver 35ms

Verification

Record traces while scrolling and resizing. Handler tasks should disappear; observer callbacks should be small and infrequent, without forced reflow warnings. Scrolling should show no dropped frames in the Frames track. In the field, compare INP and any smoothness metrics you collect for the affected pages.

Worked Example: A Documentation Table of Contents

A documentation site highlighted the current section in a sidebar table of contents. Its scroll handler called getBoundingClientRect() on all 60 headings on every scroll event and toggled classes, causing forced reflows each time after the first toggle. Scrolling on mid-range phones dropped frames visibly, and the trace showed 25–30ms handler tasks. Replacing the handler with an IntersectionObserver on headings, with a root margin defining an "active zone" near the top of the viewport, removed the scroll handler entirely. Scroll tasks disappeared, and scrolling became smooth.

Which tool replaces this handler? Decision sequence for replacing resize and scroll handlers with CSS or observers. Which tool replaces this handler? Is it only styling based on a container's width? Container queries yes no Is it an effect tied to scroll position? Scroll-driven animations yes no Does it react to an element entering view? IntersectionObserver yes no Does script logic need an element's size? ResizeObserver yes no Keep a passive handler; read first, write in rAF

Avoiding ResizeObserver Loops

A ResizeObserver callback that changes the size of an observed element can trigger itself again. Browsers process such changes within the same frame up to a depth limit, then report "ResizeObserver loop completed with undelivered notifications" and defer the rest to the next frame. This is a safety mechanism, not a crash, but it signals wasted work and possible flicker. Avoid it by observing a container whose size does not depend on what the callback changes, by changing styles of children rather than the observed element, or by applying hysteresis (different thresholds for switching on and off) so a size near a boundary does not oscillate.

javascript
let compact = false;
const ro = new ResizeObserver(([entry]) => {
  const w = entry.contentBoxSize[0].inlineSize;
  const next = compact ? w < 440 : w < 400;    // hysteresis band 400-440px
  if (next !== compact) { compact = next; entry.target.classList.toggle('compact', compact); }
});
// trade-off: hysteresis means the breakpoint differs slightly depending on the
// direction of resizing, which users rarely notice.

Migrating a Handler Step by Step

Migrate one handler at a time. Start by writing down what the handler actually needs to know (an element's width, whether a section is visible, the scroll offset) and what it changes. Choose the tool from the decision above, implement it next to the old handler behind a flag, and compare behaviour on resize and scroll. Then remove the old listener and any throttle or debounce wrapper that existed only to make it affordable. Record a trace before and after, so the improvement is documented for the next person who wonders why the code looks different.

Common Mistakes

  • Wrapping observers in debounce. Observers already coalesce; debouncing adds latency without benefit.
  • Reading layout inside observer callbacks. Use the entry's sizes; additional reads can force layout after writes in the callback.
  • One observer per element. Use one observer for many elements; it is more efficient.
  • Forgetting to disconnect. Remove observers when components unmount to avoid leaks.

Edge Cases

Window size itself. For window-level breakpoints, matchMedia listeners fire only when a media query changes, cheaper than resize handlers.

Scroll position for progress bars. Use scroll-driven animations (animation-timeline: scroll()) to avoid JavaScript entirely.

Virtual keyboards on mobile. The visualViewport API's resize event reports keyboard changes; handle it with the same read-then-write discipline.

Older browsers. All modern browsers support both observers; polyfills exist but rely on polling.

FAQ

Is ResizeObserver faster than a resize event listener?

Generally yes. It fires only when observed elements actually change size, delivers the size without forcing layout, and works per element.

When does ResizeObserver fire?

After layout and before paint in each frame where an observed element's size changed. Callbacks see up-to-date sizes.

Can IntersectionObserver replace all scroll handlers?

Most of them — visibility, lazy loading, active section tracking, infinite scroll triggers. Effects that must follow scroll position precisely are better done with scroll-driven animations.

What does "ResizeObserver loop completed with undelivered notifications" mean?

A callback changed sizes of observed elements, triggering further notifications that the browser deferred. It is not fatal but indicates a feedback loop to fix.

Should scroll listeners be passive?

Yes, unless they call preventDefault(). Passive listeners let the browser scroll without waiting for the handler.

Do container queries replace ResizeObserver?

For styling based on container size, yes, without any JavaScript. ResizeObserver is still needed when script logic depends on size, such as choosing how many chart labels to render.

Does IntersectionObserver work inside scrollable containers?

Yes. Set the root option to the scrollable container, so intersections are computed against it rather than the viewport.