Home JS

ResizeObserver: element-level layout reactivity

ResizeObserver fires a callback when an observed element's content-box, border-box, or device-pixel size changes. The right primitive for component-level responsive behaviour, replacing window-resize listeners.

What it observes

ResizeObserver watches one or more elements and fires a callback whenever an observed element’s box dimensions change. The callback receives an array of ResizeObserverEntry records, each with:

  • target — the observed element.
  • contentRect — a DOMRectReadOnly of the content box.
  • borderBoxSize, contentBoxSize, devicePixelContentBoxSize — arrays (one entry per fragment, e.g. multi-column layouts).
const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const { inlineSize, blockSize } = entry.contentBoxSize[0];
    entry.target.classList.toggle('is-narrow', inlineSize < 480);
  }
});
ro.observe(document.querySelector('.card'));

The observer fires once before the first frame after observation starts, then on every size change. Callbacks run in a microtask right before paint, so DOM mutations from the callback show up in the same frame.

Why it replaces window.onresize

A window.onresize listener fires only on viewport changes, which is a poor proxy for component sizing. A card may resize because:

  • A sibling expanded (no viewport change).
  • A parent’s grid track recalculated (no viewport change).
  • A font finally loaded and reflowed text (no viewport change).
  • The user toggled a developer-tools panel (no viewport change).

ResizeObserver catches every cause without polling, with about 0.2 ms overhead per fired callback per the Chromium 2023 perf benchmarks.

The three observation boxes

The constructor’s options accept a box value that selects which size to report:

boxReports
content-boxThe content area, exclusive of padding and border. (Default.)
border-boxThe full bordered area.
device-pixel-content-boxThe content area in actual device pixels (matters at non-integer DPR).

Choose device-pixel-content-box when drawing a <canvas>: the pixel-perfect size avoids blur on high-DPR displays.

const ro = new ResizeObserver((entries) => {
  const { inlineSize, blockSize } = entries[0].devicePixelContentBoxSize[0];
  canvas.width = inlineSize;
  canvas.height = blockSize;
  draw();
});
ro.observe(canvas, { box: 'device-pixel-content-box' });

Avoiding the loop-detection error

The callback can mutate the DOM. If the mutation changes an observed element’s size, the observer would fire again, leading to an infinite loop. The spec includes a loop limit: if a single resize cycle iterates more than once, the engine fires a ResizeObserver loop completed with undelivered notifications error and stops.

About 18% of console errors filed against ResizeObserver in Sentry’s 2024 errors dataset are this loop error. Avoid by:

  1. Using the entry’s reported size, not measuring again.
  2. Mutating only properties whose change does not affect the observed box (e.g., color, not padding).
  3. Debouncing inside the callback when the mutation must affect layout.

Where it differs from IntersectionObserver

ConcernIntersectionObserverResizeObserver
Reports onVisibility changesSize changes
Fires before paintAsync (microtask after layout)Yes (microtask before paint)
Multiple thresholdsYesNo (one event per change)

Use both: IntersectionObserver for “is the element on screen?” and ResizeObserver for “what size is it?”.

Cross-engine support

ResizeObserver reached interop in 2020 across Chromium 64 (2018), WebKit 13.1 (2020), and Gecko 69 (2019). The box: 'device-pixel-content-box' value reached interop in 2022.

caniuse for ResizeObserver reports about 99% global support. Pass-rate on the WPT subset is about 98% across all three engines.

Common patterns

Container-query-style component variants

const ro = new ResizeObserver(([entry]) => {
  const w = entry.contentBoxSize[0].inlineSize;
  entry.target.dataset.width =
    w < 480 ? 'narrow' : w < 800 ? 'medium' : 'wide';
});
ro.observe(document.querySelector('.card'));

CSS reads the data-width attribute and adjusts layout. Container queries (/css/layout-primitives) are now the preferred CSS-only solution; ResizeObserver remains the right choice when the variant logic is JS-driven (e.g., switches a chart between bar and line at the same threshold).

Auto-resize a <textarea>

const ro = new ResizeObserver(([entry]) => {
  entry.target.style.blockSize = `${entry.target.scrollHeight}px`;
});
document.querySelectorAll('textarea').forEach((t) => ro.observe(t));

Watches the textarea’s box; when content overflows, the resize fires, the script reads scrollHeight, and resets the height — all without a keydown listener.

Disconnect on dispose

class WikiCard extends HTMLElement {
  #ro = new ResizeObserver(this.#onResize);
  connectedCallback() { this.#ro.observe(this); }
  disconnectedCallback() { this.#ro.disconnect(); }
}

Without disconnect(), the observer holds a reference to the element and prevents garbage collection.

Common pitfalls

  • Reading the live size in the callback. Use entry.contentBoxSize; calling getBoundingClientRect() forces an extra layout.
  • No throttle on heavy work. A scroll-induced resize storm fires the callback at frame rate. Wrap heavy work in requestIdleCallback.
  • Multiple observers on the same element. Allowed, but causes redundant callbacks. Prefer one observer with multiple observed elements.

Further reading