Reactively observe element sizes
The window resize event was the first, but flawed, approach to size-dependent JavaScript. ResizeObserver watches individual DOM elements directly, regardless of whether the window changes, a sidebar panel opens, or a parent element grows dynamically.
Table of Contents
- 1. Why window.resize is not enough
- 2. ResizeObserver: core principle and syntax
- 3. The three box models: contentRect, borderBoxSize, devicePixelContentBoxSize
- 4. Observing multiple elements with one observer
- 5. Understanding and avoiding the observer resize loop
- 6. unobserve and disconnect: cleaning up properly
- 7. ResizeObserver in React: the useResizeObserver hook
- 8. ResizeObserver vs. window.resize vs. matchMedia vs. CSS container queries
- 9. Practical example: an adaptive chart with automatic recalculation
- 10. Summary
- 11. FAQ
1. Why window.resize is not enough
The window.resize event is a fundamental misunderstanding as a solution for size-dependent UI logic. It only fires when the browser window itself changes size, not when a container changes size due to CSS grid reflow, showing or hiding a sidebar, adding child nodes, or changing CSS variables. Complex layouts with dynamic side panels, collapsible navigation elements, or JavaScript-driven grid layouts require a solution that reacts at the element level.
ResizeObserver closes exactly this gap. It is a browser API that watches size changes of one or more DOM elements and invokes a callback whenever the size changes, regardless of the cause. That can be window resizes, but also CSS transitions, DOM mutations, font size changes from zooming, or dynamically injected content. ResizeObserver is the only correct solution to the problem "my component needs to react to its own size, regardless of why it changes".
2. ResizeObserver: core principle and syntax
The basic syntax of ResizeObserver follows the observer pattern: you create an observer instance with a callback, call observe(element), and receive a callback invocation with an array of ResizeObserverEntry objects for every size change. Each entry contains information about the observed element and its new size in various box models. The callback also receives a reference to the observer itself, which allows in-callback disconnect.
Important: ResizeObserver callbacks are not invoked synchronously for every pixel change. The browser batches multiple size changes and delivers them together in a single callback invocation. This is analogous to MutationObserver and IntersectionObserver: the browser implementation bundles notifications to reduce overhead. The callback runs after the layout step and before paint, which gives you the opportunity to make further layout changes in response to a size change without causing an extra frame.
// Basic ResizeObserver setup with entry inspection
const container = document.getElementById('responsive-container');
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
console.log(`[ResizeObserver] ${entry.target.id}: ${width}x${height}px`);
// React to size changes (e.g. switch layout modes)
if (width < 600) {
entry.target.classList.add('compact-layout');
entry.target.classList.remove('full-layout');
} else {
entry.target.classList.add('full-layout');
entry.target.classList.remove('compact-layout');
}
}
});
// Start observing
observer.observe(container);
// Optional: stop observing one element
// observer.unobserve(container);
// Disconnect all observed elements
// observer.disconnect();
3. The three box models: contentRect, borderBoxSize, devicePixelContentBoxSize
A ResizeObserverEntry provides size information in three different box models. contentRect is the oldest and most widely supported: it returns the size of the content area, without padding, border, and margin. For most layout decisions this is the right value to use. borderBoxSize is an array of ResizeObserverSize objects and returns the total size including padding and border, equivalent to what offsetWidth/offsetHeight return, but without forcing layout thrashing.
devicePixelContentBoxSize is the most precise and specialized box model: it returns the size in physical device pixels, not CSS pixels. This is essential for canvas elements that need to scale correctly on high-DPI displays (Retina). Instead of manually multiplying by window.devicePixelRatio, ResizeObserver delivers the exact value directly via devicePixelContentBoxSize. Not all browsers support all three box models at the same time; contentRect is the safe baseline, while borderBoxSize and devicePixelContentBoxSize should be used with an existence check.
4. Observing multiple elements with one observer
A single ResizeObserver can observe any number of elements. This is more efficient than creating a separate observer per element, because the overhead cost of an observer is largely fixed. In a component architecture with ten chart widgets on a dashboard page: a single ResizeObserver watches all ten containers. In the callback, you identify the changed element via entry.target and react specifically to it.
The batching behavior really pays off here: if the user drags the window very quickly on a wide monitor, multiple size changes for the same element can occur within a short time. ResizeObserver does not deliver every intermediate step, only the latest state per element in the current batch. This significantly reduces callback invocations and improves performance compared to a window-resize handler that fires on every pixel and has to be manually throttled with debouncing.
// Observing multiple elements with one ResizeObserver instance
// Map to store per-element state (avoid closure complexity)
const elementStates = new WeakMap();
const sharedObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const state = elementStates.get(entry.target);
if (!state) continue;
// Use borderBoxSize if available, fall back to contentRect
let width, height;
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
width = entry.borderBoxSize[0].inlineSize;
height = entry.borderBoxSize[0].blockSize;
} else {
width = entry.contentRect.width;
height = entry.contentRect.height;
}
// Call element-specific resize handler stored in state
state.onResize({ width, height, target: entry.target });
}
});
// Register a chart container to be observed
function observeContainer(element, onResize) {
elementStates.set(element, { onResize });
sharedObserver.observe(element);
}
function unobserveContainer(element) {
elementStates.delete(element);
sharedObserver.unobserve(element);
}
5. Understanding and avoiding the observer resize loop
The ResizeObserver resize loop is the most common mistake when using this API. It occurs when the resize callback performs an action that itself triggers a size change of the observed element. Example: the callback inserts text into an element, which grows taller as a result, which triggers the callback again, which adds more text, which makes the element grow further. The browser detects such loops and throws a ResizeObserver loop completed with undelivered notifications warning in the console.
The solution depends on the use case. In many cases, it is enough to add a guard to the logic: if the new size does not differ significantly from the last known size, take no action. For cases where the callback genuinely needs to trigger size changes, there is a trick: apply the size change to an element that sits deeper in the DOM than the observed element. ResizeObserver allows size changes of descendants, but not of ancestors or the element itself; this is the basic rule of the browser's loop prevention mechanism.
6. unobserve and disconnect: cleaning up properly
Like all observer patterns, ResizeObserver must be cleaned up properly when observed elements are removed from the DOM. observer.unobserve(element) removes a specific element from the observation list, but leaves the observer active for other elements. observer.disconnect() stops observing all elements and releases the observer's resources. The latter is the right call in the cleanup lifecycle of a component.
A subtle issue: if an observed element is removed from the DOM without calling unobserve, the observer holds a reference to the element. The element itself is garbage collected once no other references exist; ResizeObserver internally uses weak references to make that possible. Even so, explicit cleanup remains best practice: it makes the code easy to follow, avoids debugging puzzles, and clearly signals the developer's intent. In React: clean up in the useEffect return. In Web Components: disconnectedCallback.
7. ResizeObserver in React: the useResizeObserver hook
Integrating ResizeObserver into React follows the usual hook pattern. A useResizeObserver hook creates an observer instance, ties it to an element ref, and returns the current size as state. Important: the observer instance should not be recreated on every render. It is created once inside useEffect and disconnected in the cleanup return. The callback updates React state via setState, which re-renders the component.
A performance aspect: when the ResizeObserver callback calls setState, React re-renders the component. That happens on every resize event. For chart components that recalculate their canvas size on every resize, this is correct. For cases where the state is rarely needed, you can store the observer size in a useRef (no re-render) and only read it for specific calculations. The choice between ref and state depends on whether the size change should trigger a re-render.
// useResizeObserver React hook, clean lifecycle management
import { useEffect, useRef, useState } from 'react';
function useResizeObserver(ref) {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new ResizeObserver((entries) => {
// Entries is an array, we only observe one element here
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
setSize({ width, height });
});
observer.observe(element);
// Cleanup: disconnect observer when component unmounts
return () => observer.disconnect();
}, [ref]);
return size;
}
// Usage in a Chart component
function AdaptiveChart({ data }) {
const containerRef = useRef(null);
const { width, height } = useResizeObserver(containerRef);
return (
<div ref={containerRef} style={{ width: '100%' }}>
<canvas
width={width}
height={height || 300}
data-chart-width={width}
/>
</div>
);
}
8. ResizeObserver vs. window.resize vs. matchMedia vs. CSS container queries
The four approaches to size-dependent layouts each have clearly distinct strengths. window.resize is the oldest solution and is only correct when you actually want to react to window size changes, not element sizes. matchMedia is ideal for breakpoint-based logic in JavaScript, since it only invokes callbacks at breakpoint transitions, not on every pixel. ResizeObserver is the only solution that reacts to individual element sizes, regardless of the cause of the change.
CSS container queries have been available in all modern browsers since 2023 and handle, for many use cases, what used to require JavaScript: they let you apply CSS rules based on the size of a container element. For purely visual adjustments (column count, font size, spacing), CSS container queries are the superior solution: no JavaScript, no observer, no layout thrashing. ResizeObserver remains indispensable when JavaScript genuinely needs to react to the size: canvas scaling, D3 chart recalculations, dynamic data layouts.
| Method | Reacts to | Granularity | Ideal for |
|---|---|---|---|
| ResizeObserver | Element size (any cause) | Per element | Canvas, charts, adaptive components |
| window.resize | Window size change | Global | Viewport-dependent calculations |
| matchMedia | Breakpoint transitions | Breakpoints | JS breakpoint logic without pixel spam |
| CSS container queries | Container width (CSS only) | Per container | Purely visual adjustments without JS |
9. Practical example: an adaptive chart with automatic recalculation
Charts built on canvas or SVG need to be redrawn when the container's size changes. This is the classic use case for ResizeObserver: the chart container is observed, canvas attributes and chart scales are recalculated on every size change, and the chart is re-rendered without visible flicker. Without ResizeObserver you would either rely on window.resize (wrong: it does not react to panel changes) or poll dimensions via offsetWidth inside an animation frame (very inefficient).
An important detail: the canvas element must have both the HTML attribute width/height and the CSS property width: 100% set correctly. The HTML attribute determines the internal resolution of the canvas buffer, while the CSS property determines the displayed size in the document. For Retina displays, the ResizeObserver value must be multiplied by devicePixelContentBoxSize or window.devicePixelRatio to get sharp output. The ResizeObserver callback provides this value directly via devicePixelContentBoxSize without further calculation.
// Adaptive canvas chart with ResizeObserver and DPR scaling
class AdaptiveChart {
constructor(container) {
this.container = container;
this.canvas = container.querySelector('canvas');
this.ctx = this.canvas.getContext('2d');
this.data = [];
this.observer = new ResizeObserver((entries) => {
const entry = entries[0];
// Use devicePixelContentBoxSize for sharp rendering on HiDPI
let physicalWidth, physicalHeight;
if (entry.devicePixelContentBoxSize) {
physicalWidth = entry.devicePixelContentBoxSize[0].inlineSize;
physicalHeight = entry.devicePixelContentBoxSize[0].blockSize;
} else {
// Fallback: manual DPR calculation
const dpr = window.devicePixelRatio || 1;
physicalWidth = Math.round(entry.contentRect.width * dpr);
physicalHeight = Math.round(entry.contentRect.height * dpr);
}
// Set canvas buffer size to physical pixels
this.canvas.width = physicalWidth;
this.canvas.height = physicalHeight;
// CSS size via contentRect (CSS pixels)
this.canvas.style.width = `${entry.contentRect.width}px`;
this.canvas.style.height = `${entry.contentRect.height}px`;
this.render(); // Redraw after resize
});
this.observer.observe(container);
}
render() {
const { width, height } = this.canvas;
this.ctx.clearRect(0, 0, width, height);
// ... draw chart scaled to width/height
}
destroy() {
this.observer.disconnect();
}
}
10. Summary
ResizeObserver is the modern, correct solution for every scenario in which JavaScript must react to element size changes. It replaces fragile window-resize event handlers with debouncing and offsetWidth polling with a browser-native observer API that offers batching and three box models. The three box models cover all use cases: contentRect for layout decisions, borderBoxSize for total sizes, devicePixelContentBoxSize for HiDPI canvas rendering.
The most important rules: always cleanly separate observers from observed elements, calling unobserve or disconnect in the cleanup lifecycle. Avoid the observer resize loop with guards or by following the principle "only change descendant elements". Prefer CSS container queries for purely visual adjustments. Reserve ResizeObserver for cases where JavaScript genuinely needs to react to size: canvas scaling, programmatic layout calculations, adaptive data visualizations.
Mironsoft
JavaScript DOM APIs, responsive architectures, and canvas performance
Layouts that react to every size change?
We analyze your layout logic, replace window-resize hacks with clean ResizeObserver implementations, and integrate adaptive chart and canvas components into your React or Web Component architecture.
Observer audit
Replace window-resize handlers and offsetWidth polling with ResizeObserver
Canvas & HiDPI
devicePixelContentBoxSize integration for sharp Retina canvas output
React hooks
Clean useResizeObserver hooks with correct lifecycle management
ResizeObserver, the essentials at a glance
Element-level observation
Reacts to size changes of individual elements, regardless of the cause. Correct solution for dynamic layouts with sidebars, panels, and flexible containers.
Three box models
contentRect for layout decisions. borderBoxSize for total sizes. devicePixelContentBoxSize for HiDPI canvas rendering without manual DPR multiplication.
Loop prevention
The callback must not trigger a size change on the observed element or its ancestors. Only change descendant elements. Use guard checks for minor changes.
Clean cleanup
observer.unobserve(el) for individual elements. observer.disconnect() on unmount/disconnectedCallback. WeakMap for per-element state, no memory leak on DOM removal.