Modern frontend architectures have moved from global window.resize listeners to targeted, element-level observation. ResizeObserver delivers dimension changes for exactly the DOM nodes you specify, asynchronously, after the browser's layout phase — eliminating the synchronous layout thrashing that plagued earlier patterns. This page covers the full production workflow: spec details, step-by-step implementation, rAF throttling, framework teardown discipline, and bridging to CSS container queries.
For the broader context of how this fits alongside visibility and scroll patterns, see Implementation Patterns for Viewport & Resize Tracking.
Why Element-Level Observation Replaces Window Resize
Historically, window.addEventListener('resize') was the default mechanism for responsive adjustments. In component-driven architectures this forces the browser to recompute styles and layout across the entire DOM tree on every pixel change — sometimes dozens of times per second during interactive drag-resizing. The result is measurable layout thrashing, increased main-thread blocking, and degraded Time to Interactive.
Targeted DOM observation decouples component dimensions from global viewport events. The browser queues resize notifications only for explicitly observed elements, and defers callback execution until the layout phase completes. CPU overhead drops because observation scope is isolated, and downstream work only runs for the elements that actually changed.
The diagram below shows how the browser event loop routes resize notifications differently under each approach.
Spec / Signature Reference
The table below covers every property and method you interact with in a ResizeObserver workflow.
| API surface | Type | Notes |
|---|---|---|
new ResizeObserver(callback) |
Constructor | callback receives ResizeObserverEntry[] and the observer instance |
observer.observe(target, options?) |
Method | options.box: "content-box" (default), "border-box", "device-pixel-content-box" |
observer.unobserve(target) |
Method | Removes one element; does not affect other observed targets |
observer.disconnect() |
Method | Stops all observations and clears internal queues |
entry.target |
Element |
The observed DOM element |
entry.contentRect |
DOMRectReadOnly |
Content box — excludes padding/border; legacy compatibility value |
entry.borderBoxSize |
ResizeObserverSize[] |
Includes padding + border; preferred for CSS width/height parity |
entry.contentBoxSize |
ResizeObserverSize[] |
Content box as a ResizeObserverSize array (logical properties aware) |
entry.devicePixelContentBoxSize |
ResizeObserverSize[] |
Physical pixels — use for canvas bitmap sizing |
ResizeObserverSize.inlineSize |
number |
Width in horizontal writing modes |
ResizeObserverSize.blockSize |
number |
Height in horizontal writing modes |
borderBoxSize and contentBoxSize return arrays to support multi-fragment elements (e.g. columns). In practice, read index [0] for single-fragment elements.
Step-by-Step Implementation
Each step below is independently testable in a browser DevTools console.
Step 1 — Basic observation
// TypeScript
const target = document.querySelector<HTMLElement>('.resize-target')!;
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
for (const entry of entries) {
// Prefer borderBoxSize for values that match CSS box-sizing: border-box
const [box] = entry.borderBoxSize;
console.log(`inline: ${box.inlineSize}px block: ${box.blockSize}px`);
}
});
observer.observe(target, { box: 'border-box' });
// JS fallback (no types):
// const observer = new ResizeObserver(entries => { ... });
The callback fires once immediately on observe() with the element's current dimensions — no need for a separate getBoundingClientRect() call.
Step 2 — Share one observer across multiple elements
// One instance, many targets — entries array contains only changed elements per frame
const sharedObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
for (const entry of entries) {
const id = (entry.target as HTMLElement).dataset.observerId;
handleResize(id, entry.borderBoxSize[0]);
}
});
document.querySelectorAll<HTMLElement>('[data-observer-id]').forEach(el => {
sharedObserver.observe(el, { box: 'border-box' });
});
Sharing one instance is more efficient than one-observer-per-element because the browser batches all changed elements into a single callback invocation per frame.
Step 3 — rAF throttling to prevent layout thrashing
Wrap the callback in requestAnimationFrame to align DOM reads and writes with the browser's rendering cycle. This is the same principle described in syncing observer callbacks with requestAnimationFrame.
let rafId: number | null = null;
let pending: ResizeObserverEntry[] = [];
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
pending = entries; // overwrite — we only need the latest frame
if (!rafId) {
rafId = requestAnimationFrame(() => {
processBatch(pending);
rafId = null;
pending = [];
});
}
});
function processBatch(entries: ResizeObserverEntry[]): void {
for (const entry of entries) {
// safe to write DOM here — inside rAF, layout already read
(entry.target as HTMLElement).style.setProperty(
'--measured-width',
`${entry.borderBoxSize[0].inlineSize}px`
);
}
}
Step 4 — Teardown
Always call disconnect() when the component or module is destroyed. Failing to do so retains DOM element references and prevents garbage collection — a common source of memory leaks in long-running observers.
// Called on component unmount / route change / page unload
observer.disconnect();
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
Production-Ready Wrapper
Raw ResizeObserver usage lacks safeguards against detached-node leaks, rAF duplication, and missing teardown. The class below composes ResizeObserver with MutationObserver for automatic node-removal detection, an AbortController to cancel in-flight async work, and a WeakSet for safe multi-target tracking.
export class CleanupAwareResizeObserver {
private ro: ResizeObserver;
private mo: MutationObserver;
private rafId: number | null = null;
private pending: ResizeObserverEntry[] = [];
private abort = new AbortController();
private tracked = new WeakSet<Element>();
private cb: (entries: ResizeObserverEntry[]) => void;
constructor(callback: (entries: ResizeObserverEntry[]) => void) {
this.cb = callback;
this.ro = new ResizeObserver((entries) => {
this.pending = entries;
if (!this.rafId) {
this.rafId = requestAnimationFrame(() => this.flush());
}
});
// Auto-unobserve elements removed from the DOM
this.mo = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.removedNodes) {
if (node instanceof Element && this.tracked.has(node)) {
this.unobserve(node);
}
}
}
});
}
observe(target: Element, options?: ResizeObserverOptions): void {
if (this.abort.signal.aborted) return;
this.ro.observe(target, options);
this.tracked.add(target);
this.mo.observe(document.body, { childList: true, subtree: true });
}
unobserve(target: Element): void {
this.ro.unobserve(target);
this.tracked.delete(target);
}
disconnect(): void {
this.abort.abort();
this.ro.disconnect();
this.mo.disconnect();
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.pending = [];
}
private flush(): void {
if (!this.abort.signal.aborted) {
this.cb(this.pending);
}
this.rafId = null;
this.pending = [];
}
}
// Plain JS equivalent (no types, same structure):
// class CleanupAwareResizeObserver { constructor(callback) { this.cb = callback; ... } }
WeakSet allows the garbage collector to reclaim element references automatically if a component unmounts without calling disconnect(). AbortController ensures pending microtasks cancel cleanly during teardown.
Configuration Variants
box option |
What it measures | When to use |
|---|---|---|
"content-box" (default) |
Width/height excluding padding and border | Text layout, intrinsic content sizing |
"border-box" |
Width/height including padding and border | Matching CSS box-sizing: border-box; grid/flex layouts |
"device-pixel-content-box" |
Physical device pixels (no CSS scaling) | <canvas> bitmap sizing, HiDPI rendering |
Throttle vs debounce trade-off: rAF throttling (Step 3 above) maintains responsiveness during interactive drag-resize at ~60 fps. Debouncing (setTimeout after last event) is appropriate when you only care about the final settled dimension — for example, triggering a network request after a panel resize. Avoid debouncing for canvas or chart updates where intermediate sizes matter. For a detailed treatment of these strategies across observer types, see callback throttling and debouncing.
Edge Cases and Gotchas
"ResizeObserver loop limit exceeded" warning. This fires when your callback synchronously mutates the observed element's dimensions, which causes the browser to detect another resize before the current frame commits. Always defer DOM mutations with requestAnimationFrame or queueMicrotask, never write synchronously to geometry-affecting properties inside the callback.
Initial delivery on observe(). The callback fires synchronously during the next layout pass after observe() is called, even if the element hasn't changed size. If your callback does expensive work, guard it with a firstRun flag.
Subpixel rounding. inlineSize/blockSize values are floating-point. Two sequential callbacks may report values like 399.984 and 400 for the same visual width. Use Math.round() or a threshold check when deciding whether a meaningful change occurred.
Detached / hidden elements. ResizeObserver does not fire for elements with display: none or elements removed from the DOM. If you remove and re-insert an element, you must call observe() again — or use the MutationObserver pairing in the production wrapper above to detect removal automatically.
Cross-origin iframes. An observer created in the parent document cannot observe content inside a cross-origin iframe. For same-origin iframes, instantiate the observer from within the iframe's own script context and use postMessage to relay dimension data to the parent.
CSS transforms and zoom. ResizeObserver reports the element's layout size, not its visual/rendered size after transforms. A scale(2) transform does not change inlineSize; use getBoundingClientRect() if you need the visual bounding box.
Framework Integration Patterns
React hook
import { useEffect, useRef } from 'react';
import type { RefObject } from 'react';
export function useResizeObserver<T extends Element>(
ref: RefObject<T>,
callback: (entry: ResizeObserverEntry) => void
): void {
useEffect(() => {
const el = ref.current;
if (!el) return;
// Gate on window availability for SSR safety
if (typeof window === 'undefined' || !('ResizeObserver' in window)) return;
const ro = new ResizeObserver((entries) => {
if (entries[0]) callback(entries[0]);
});
ro.observe(el, { box: 'border-box' });
return () => ro.disconnect(); // React Strict Mode calls this twice — safe
}, [ref, callback]);
}
Pass a stable callback reference (via useCallback) to avoid re-subscribing on every render.
Vue composable
import { onMounted, onUnmounted, type Ref } from 'vue';
export function useResizeObserver(
targetRef: Ref<Element | null>,
callback: (entry: ResizeObserverEntry) => void
): void {
let ro: ResizeObserver | null = null;
onMounted(() => {
if (!targetRef.value) return;
ro = new ResizeObserver((entries) => {
if (entries[0]) callback(entries[0]);
});
ro.observe(targetRef.value, { box: 'border-box' });
});
onUnmounted(() => {
ro?.disconnect();
ro = null;
});
}
Vue's reactivity system does not track DOM observers — explicit teardown in onUnmounted is mandatory.
Angular directive
import { Directive, ElementRef, OnInit, OnDestroy, Output, EventEmitter } from '@angular/core';
@Directive({ selector: '[appResizeObserver]' })
export class ResizeObserverDirective implements OnInit, OnDestroy {
@Output() dimensionChange = new EventEmitter<ResizeObserverEntry>();
private ro!: ResizeObserver;
constructor(private el: ElementRef<Element>) {}
ngOnInit(): void {
this.ro = new ResizeObserver((entries) => {
if (entries[0]) this.dimensionChange.emit(entries[0]);
});
this.ro.observe(this.el.nativeElement, { box: 'border-box' });
}
ngOnDestroy(): void {
this.ro.disconnect();
}
}
Use @ViewChild with { static: false } when observing a child element that appears conditionally, to ensure the DOM node exists before observe() runs.
Bridging to CSS Container Queries
CSS @container queries provide hardware-accelerated responsive logic with near-zero runtime cost. JavaScript observers remain necessary for cross-browser fallbacks, dynamic state computation, canvas sizing, and analytics. The optimal approach syncs observer measurements into CSS custom properties, letting CSS handle layout while JS handles imperative logic.
const syncToCSS = (entries: ResizeObserverEntry[]): void => {
for (const entry of entries) {
const { inlineSize, blockSize } = entry.borderBoxSize[0];
const el = entry.target as HTMLElement;
el.style.setProperty('--el-width', `${inlineSize}px`);
el.style.setProperty('--el-height', `${blockSize}px`);
}
};
const observer = new ResizeObserver(syncToCSS);
observer.observe(document.querySelector('.panel')!, { box: 'border-box' });
/* CSS reads the custom properties set by JS */
.panel {
container-type: inline-size;
}
.panel__label {
font-size: clamp(0.75rem, calc(var(--el-width) * 0.03), 1.25rem);
}
For a full walkthrough of this hybrid pattern, including polyfill strategy for browsers that lack native container query support, see Detecting Container Queries with ResizeObserver.
Debugging Checklist
DevTools steps:
- Open the Performance panel and record a 5-second trace while drag-resizing the observed element. Filter the flame chart by "ResizeObserver" to isolate callback time. Callbacks should complete in under 2 ms per frame.
- Enable Layout and Paint overlays. A synchronous layout block inside the callback appears as a red bar on the main thread timeline. Refactor to async state updates to eliminate it.
- Take a Heap Snapshot before and after component unmount. Search for "ResizeObserver" and "Detached HTMLElement". Any retained references indicate missing
disconnect()calls. - Watch the Console for
ResizeObserver loop limit exceeded. Reproduce the warning, then addrequestAnimationFramewrapping to the offending mutation.
Checklist:
- No
ResizeObserver loop limit exceeded -
disconnect() -
observe() - Canvas elements observed with
"device-pixel-content-box"
For reducing layout thrashing caused by observer callbacks that mix reads and writes, see the dedicated page on DOM query minimization.
FAQ
What is the difference between contentBoxSize and borderBoxSize in a ResizeObserverEntry?
contentBoxSize excludes padding and border; borderBoxSize includes them. For most layout calculations you want borderBoxSize so the reported value matches what CSS width/height reports on an element using box-sizing: border-box. Read from borderBoxSize[0].inlineSize rather than the legacy contentRect.width when box model accuracy matters.
Why does ResizeObserver fire immediately on observe()?
The spec requires an initial delivery so your callback receives the element's current dimensions without needing a separate getBoundingClientRect() call. If the initial call triggers unwanted side effects, guard with a firstRun flag:
let firstRun = true;
const ro = new ResizeObserver((entries) => {
if (firstRun) { firstRun = false; return; }
handleResize(entries);
});
How do I avoid the "ResizeObserver loop limit exceeded" warning?
Never synchronously mutate an observed element's dimensions inside the callback — that triggers another layout recalculation before the current frame commits. Defer mutations:
const ro = new ResizeObserver((entries) => {
requestAnimationFrame(() => {
for (const entry of entries) {
(entry.target as HTMLElement).style.height = computeNewHeight(entry) + 'px';
}
});
});
Can I share one ResizeObserver across many elements?
Yes. A single ResizeObserver instance can observe an unlimited number of elements. The callback receives an array of entries, one per element that changed dimensions within that frame. Sharing is more efficient than creating one instance per element because the browser batches all changed elements into a single callback invocation.
Does ResizeObserver work inside iframes?
An observer created in the parent document cannot observe elements inside a cross-origin iframe. For same-origin iframes, instantiate the observer from within that iframe's own JavaScript context, then relay dimension data to the parent via postMessage. Cross-origin constraint is a security boundary enforced by the browser — there is no workaround.
Related
- Detecting Container Queries with ResizeObserver
- ResizeObserver Mechanics and Triggers
- Preventing Memory Leaks in Long-Running Observers
- Callback Throttling and Debouncing
- Reducing Layout Thrashing with ResizeObserver
↑ Back to Implementation Patterns for Viewport & Resize Tracking