Guide

Resize Observer API explained

Harbor Commerce’s merchant analytics dashboard rendered six Chart.js canvases inside a collapsible sidebar layout. The team wired window.addEventListener('resize', redrawAll) so charts reflowed when users rotated a phone. That worked for orientation changes — but it also fired when users toggled the sidebar, opened a filter drawer, or resized a split panel, because those actions changed container width without changing the viewport. Every sidebar click triggered six full chart redraws and a 120 ms main-thread stall. Replacing the global listener with ResizeObserver — one observer per chart wrapper that called chart.resize() only when that wrapper’s box changed — cut wasted redraws by 84% and dropped INP on the toggle interaction from 210 ms to 48 ms. The Resize Observer API is a browser-native way to ask “when did this element’s size change?” and receive batched, asynchronous callbacks. It powers responsive charts, dynamic text truncation, sticky layout recalculation, embed sizing, and component libraries that must react to parent width — not just the window. This guide covers observer lifecycle, box size entry fields, pairing with container queries and Intersection Observer, the Harbor Commerce dashboard refactor, a technique decision table, pitfalls, and a production checklist.

Why window resize listeners miss the real problem

Modern layouts are nested: sidebars, split panes, CSS grid areas, and responsive components that reflow inside a parent without the viewport changing. A window resize event fires only when the browser window dimensions change — not when:

  • A sidebar collapses from 280 px to 64 px icons-only mode.
  • A modal opens and squeezes the main content column.
  • Flex or grid siblings grow because one child loads async content.
  • Text wraps after a font loads or a translation string arrives.
  • An accordion section expands and shrinks a chart container below it.

Developers used to poll element.offsetWidth inside requestAnimationFrame loops or attach MutationObserver to watch DOM changes — both expensive and indirect. ResizeObserver delivers size-change notifications directly from the layout engine, batched per frame, without forcing synchronous layout reads in your scroll or input handlers.

ResizeObserver vs Intersection Observer

These APIs solve different questions. Intersection Observer reports whether an element intersects a root region (viewport or scroll container) — useful for lazy loading and infinite scroll. ResizeObserver reports when an element’s content or border box dimensions change. A chart can stay fully visible while its container width halves; only ResizeObserver notices. Use both together when a component needs to know visibility and available width.

API basics: observe, callback, disconnect

The API is small. Create one observer, pass a callback, call observe(target) on each element whose size you care about:

const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const { width, height } = entry.contentRect;
    // legacy shorthand — still widely supported
    chart.resize(width, height);
  }
});

ro.observe(chartContainer);
// later: ro.unobserve(chartContainer) or ro.disconnect()

Key methods:

  • observe(element) — start watching an element. Observing the same element twice is a no-op.
  • unobserve(element) — stop watching one target without tearing down others.
  • disconnect() — stop watching all targets and release the observer. Call this on component unmount.

Reading size from entries

Each callback receives an array of ResizeObserverEntry objects. Modern browsers expose granular box sizes:

  • entry.contentBoxSize — the content box (inside padding). Prefer this for canvas and SVG layout.
  • entry.borderBoxSize — includes padding and border. Use when your drawing surface fills the outer box.
  • entry.contentRect — legacy DOMRectReadOnly with width and height. Still fine for most cases; map to the newer arrays when you need sub-pixel precision or multiple fragments.
  • entry.devicePixelContentBoxSize — physical pixels for high-DPI canvas backing stores. Essential for crisp chart rendering on Retina displays.

Callbacks are delivered before paint in the same frame when possible, but you should still keep work cheap. Debounce heavy redraws (chart recompute, layout diff) if multiple observed elements resize in one frame.

Common use cases

Responsive charts and canvases

Chart.js, D3, ECharts, and Mapbox GL all need an explicit resize() when their container changes. Observe the wrapper div, read contentBoxSize, set the canvas backing store to width × devicePixelRatio, and call the library’s resize API. This is more reliable than window listeners and survives sidebar animations.

Dynamic text truncation and “show more”

Clamp headings to two lines with CSS -webkit-line-clamp, then use ResizeObserver on the text node’s parent to detect when available width changes and re-measure whether the overflow button should appear. Container queries handle static breakpoints; ResizeObserver handles JS-driven truncation logic.

Virtual lists and split layouts

Virtual scrollers need the viewport height of their scroll container. When a header collapses or a bottom sheet appears, the scrollport height changes without a window resize. Observe the scroll container and pass the new height to your row-height calculator.

Third-party embeds and iframes

Embedded widgets (maps, payment forms, video players) often assume a fixed width. Observe the slot element and postMessage the new dimensions to the iframe, or trigger the vendor’s responsive API.

When CSS alone is enough

If your component only needs different styles at certain widths — stack columns, hide labels, change font size — prefer container queries or media queries. Reserve ResizeObserver for imperative APIs (canvas, WebGL, measuring text overflow) that CSS cannot drive.

Harbor Commerce dashboard refactor (worked example)

The merchant analytics page had six chart cards in a responsive CSS grid. A 240 px sidebar sat left; toggling it animates width over 200 ms. The old code:

  1. Attached one window resize listener on page load.
  2. On fire, looped all six Chart.js instances and called chart.resize().
  3. Sidebar toggle did not fire window resize — so engineers also called redrawAll() manually from the sidebar click handler, duplicating logic.

The refactor:

  1. Created a module-level ResizeObserver with a WeakMap<Element, Chart> from container to chart instance.
  2. Each card mounted with ro.observe(cardEl); unmount called ro.unobserve(cardEl).
  3. The callback read entry.contentBoxSize[0].inlineSize and called chart.resize() only for entries in the batch.
  4. Removed the window listener and the manual sidebar redraw.

Results: sidebar toggle INP dropped from 210 ms to 48 ms; orientation changes still reflowed correctly because the grid cells resize with the viewport; no chart drew at stale dimensions after the 200 ms CSS transition because ResizeObserver fires on each intermediate layout during the animation.

React and framework patterns

Pitfalls in React and Vue are similar:

  • Do not create an observer per render. Use useRef + useEffect to construct once and disconnect on cleanup.
  • Prefer a shared observer. One module-level ResizeObserver with a Map from element to callback scales to dozens of chart cards.
  • Guard against zero-size containers. Hidden tabs and display: none parents report 0×0. Skip resize logic or defer until the element is visible (pair with Intersection Observer or tab activation).
  • Libraries: @react-hook/resize-observer and component libraries (Recharts, MUI) wrap the API; understand the underlying observer so you can debug double-subscribe leaks.

Technique decision table

Need Recommended approach Why
Stack columns when a card is narrower than 400 px CSS container queries Declarative, no JS, works without resize callbacks
Redraw Chart.js when a flex sibling collapses ResizeObserver on chart wrapper Window resize misses parent-driven width changes
Lazy-load images below the fold Intersection Observer Visibility, not size, is the trigger
React to viewport breakpoint (mobile vs desktop) matchMedia or CSS media queries Viewport-level, not element-level
Detect DOM node insertion MutationObserver ResizeObserver does not fire on mount alone unless size is non-zero
Crisp canvas on Retina displays ResizeObserver + devicePixelContentBoxSize Sets backing store to physical pixels

Common pitfalls

  • Infinite resize loops — callback changes element size (e.g. sets explicit height) which triggers another callback. Break the cycle with threshold checks or only updating when delta exceeds 1 px.
  • Observing document.body or html — fires on every page mutation; observe specific containers instead.
  • Heavy work in the callback — chart full-data recomputes belong in requestAnimationFrame or a debounced scheduler, not inline.
  • Forgetting disconnect on SPA route change — leaks observers and redraws off-screen charts.
  • Zero-size hidden panels — resizing a chart inside a closed tab to 0×0 breaks aspect ratio; resize on tab activation.
  • Replacing CSS container queries unnecessarily — JS resize handlers for pure styling add complexity and main-thread cost.
  • Assuming synchronous dimensions — read sizes from the entry object in the callback, not from a stale closure.

Production checklist

  • Identify components that call imperative resize() (canvas, maps, video).
  • Observe the nearest stable wrapper, not the canvas element itself if padding differs.
  • Share one ResizeObserver across multiple targets where possible.
  • Read contentBoxSize or devicePixelContentBoxSize for new code; fall back to contentRect.
  • Guard callbacks: skip when width or height is zero or unchanged within 1 px.
  • Call unobserve or disconnect on component unmount and route teardown.
  • Defer chart init until the container has non-zero size (tab visible, accordion open).
  • Prefer container queries for layout-only responsiveness; use ResizeObserver for imperative APIs.
  • Profile sidebar, drawer, and split-pane interactions for INP before and after.
  • Document which elements are observed so future layout refactors do not duplicate window listeners.

Key takeaways

  • ResizeObserver fires when an element’s box size changes — including parent-driven reflows that never touch the window.
  • Use observe, unobserve, and disconnect deliberately; share one observer across many targets to avoid leaks.
  • Pair with container queries: CSS for styling breakpoints, ResizeObserver for canvas, charts, and text measurement.
  • Keep callbacks cheap; guard against zero-size hidden containers and infinite resize feedback loops.
  • Intersection Observer answers visibility; ResizeObserver answers dimensions — use the right tool for each.

Related reading