ResizeObserver replaces polling and window.resize listeners with a precise, asynchronous measurement layer — but its event-loop integration, box model variants, and loop-prevention rules require deliberate handling. This page explains exactly when and why callbacks fire, which box model you are actually measuring, and how to avoid the notorious "loop limit exceeded" error in production.
Concept Framing
The fundamental tension in element-dimension observation is timing: you need to read layout metrics without forcing a synchronous reflow, and you must not write back to the DOM in a way that immediately triggers another observation cycle. ResizeObserver is specified to resolve this by slotting callback delivery into the browser's rendering pipeline — after layout, before paint — giving you a safe read window. This makes it architecturally distinct from both event listeners and Promise microtasks.
As part of the Core Observer Fundamentals & Browser APIs family, ResizeObserver shares the observer pattern's lifecycle vocabulary (observe, unobserve, disconnect) with IntersectionObserver and MutationObserver, but it differs in when and how deeply it integrates with the layout engine. Understanding these mechanics is the prerequisite for applying the observer lifecycle and memory management patterns that prevent detached-node leaks in long-lived applications.
How ResizeObserver Fits into the Rendering Pipeline
The diagram below shows where ResizeObserver callback delivery sits relative to the browser's frame lifecycle and how it differs from legacy window.resize event dispatch.
Spec / Signature Reference Table
| Property / Method | Type | Description |
|---|---|---|
new ResizeObserver(callback) |
Constructor | callback receives (entries: ResizeObserverEntry[], observer: ResizeObserver) |
observe(target, options?) |
Method | Begins observing target; options.box selects the box model |
unobserve(target) |
Method | Stops observing a single element; does not disconnect the instance |
disconnect() |
Method | Stops all observations and releases internal references |
entry.target |
Element |
The element whose size changed |
entry.contentRect |
DOMRectReadOnly |
Legacy content-box rect — available in all browsers but deprecated in favour of contentBoxSize |
entry.contentBoxSize |
ResizeObserverSize[] |
Content-box inlineSize + blockSize per writing mode |
entry.borderBoxSize |
ResizeObserverSize[] |
Border-box dimensions (padding + border included) |
entry.devicePixelContentBoxSize |
ResizeObserverSize[] |
Sub-pixel-accurate physical pixels (Chrome 84+, not in Safari) |
options.box |
"content-box" | "border-box" | "device-pixel-content-box" |
Selects which box model triggers callbacks; default is "content-box" |
Box Model Quick Reference
| Box model option | Includes padding? | Includes border? | Use case |
|---|---|---|---|
content-box (default) |
No | No | Text-flow containers, CSS width/height tracking |
border-box |
Yes | Yes | Matching box-sizing: border-box layouts, drag handles |
device-pixel-content-box |
No | No | Canvas pixel-ratio sync, HiDPI rendering (Chrome only) |
Step-by-Step Implementation
Step 1 — Instantiate a shared observer
Create a single ResizeObserver instance for the page or component tree. Using one instance per element is legal but wastes memory and callback-dispatch overhead.
// TypeScript — shared observer with per-element callback dispatch
interface ResizeCallback {
(entry: ResizeObserverEntry): void;
}
const registry = new WeakMap<Element, ResizeCallback>();
const sharedObserver = new ResizeObserver(
(entries: ResizeObserverEntry[]) => {
for (const entry of entries) {
const cb = registry.get(entry.target);
if (cb) cb(entry);
}
}
);
// Plain JS equivalent: remove type annotations; WeakMap usage is identical
Step 2 — Attach a target with the correct box model
function observeElement(
el: Element,
callback: ResizeCallback,
box: ResizeObserverBoxOptions = "border-box"
): void {
registry.set(el, callback);
sharedObserver.observe(el, { box });
// The first callback fires asynchronously with the element's current dimensions —
// use this baseline instead of getBoundingClientRect() to avoid forced reflow.
}
Step 3 — Read dimensions from the entry
Prefer borderBoxSize or contentBoxSize over the deprecated contentRect. Both are arrays to account for multi-column or writing-mode fragmentation; use index [0] for single-box elements.
function handleResize(entry: ResizeObserverEntry): void {
// Prefer the new array-based properties
const [size] = entry.borderBoxSize;
const width = size?.inlineSize ?? entry.contentRect.width;
const height = size?.blockSize ?? entry.contentRect.height;
// SAFE: reading layout properties inside the callback does not cause reflow
console.log(`${entry.target.id}: ${width}px × ${height}px`);
// UNSAFE without rAF: writing back to layout properties here
// can trigger the loop-limit error — see Edge Cases section below
}
Step 4 — Defer DOM writes with requestAnimationFrame
If your callback must write back to the DOM (setting style.height, updating a canvas size), schedule the write in a requestAnimationFrame call. This pushes the DOM mutation into the next frame, breaking the same-frame re-observation cycle.
function handleResizeWithWrite(entry: ResizeObserverEntry): void {
const [size] = entry.borderBoxSize;
const inlineSize = size?.inlineSize ?? entry.contentRect.width;
// Schedule the DOM write to the next frame
requestAnimationFrame(() => {
const canvas = document.getElementById("chart-canvas") as HTMLCanvasElement;
if (canvas) {
canvas.width = Math.round(inlineSize * devicePixelRatio);
canvas.style.width = `${inlineSize}px`;
}
});
}
Step 5 — Clean up on teardown
function unobserveElement(el: Element): void {
sharedObserver.unobserve(el);
registry.delete(el);
}
// Call when the entire feature is torn down (route change, app unmount)
function teardown(): void {
sharedObserver.disconnect();
// WeakMap keys are garbage-collected automatically — no manual clear needed
}
Trigger Conditions & What Does Not Fire
Not every visual change triggers a ResizeObserver callback. The table below clarifies which mutations count.
| Mutation | Fires callback? | Why |
|---|---|---|
element.style.width = "300px" |
Yes | Alters layout-box dimensions |
element.style.padding = "20px" |
Yes (border-box only) | Changes border-box size |
element.style.transform = "scale(2)" |
No | Compositor-only; layout box unchanged |
element.style.opacity = "0.5" |
No | Paint property; no layout change |
| Parent container resizes | Yes | Child layout recalculated |
devicePixelRatio change (zoom) |
No (content-box) | Use device-pixel-content-box for DPR tracking |
observe() called on existing element |
Yes (once) | Initial baseline entry always delivered |
display: none set |
Yes | Content-box collapses to zero |
| Scroll (no size change) | No | Position change only |
Edge Cases & Gotchas
The loop-limit error
The browser logs ResizeObserver loop limit exceeded when a callback synchronously mutates the observed element in a way that forces the browser to re-run observation within the same rendering update. This is not a crash — the browser skips the re-observation for that frame and recovers — but the error indicates your callback is creating a measurement feedback loop. The fix is consistent: defer writes to requestAnimationFrame (see Step 4 above).
Subpixel rounding inconsistencies
entry.contentRect values are rounded to CSS pixels. borderBoxSize[0].inlineSize and contentBoxSize[0].inlineSize return floating-point values that better reflect sub-pixel layouts. When you need pixel-perfect canvas rendering, use devicePixelContentBoxSize (Chrome 84+) and feature-detect it:
const physicalWidth =
entry.devicePixelContentBoxSize?.[0]?.inlineSize ??
Math.round(entry.contentRect.width * devicePixelRatio);
Iframe containment
A ResizeObserver created in a parent frame cannot observe elements inside a cross-origin iframe — the observer's root is bounded by the browsing context that created it. Same-origin iframes work, but the iframe's own document must host the observer.
Coalesced frames during fast resize
On a touch device or when the user resizes the window rapidly, the browser coalesces multiple layout cycles into one rendering update. Your callback receives only the final entry for each observed element in that batch, not one entry per intermediate size. Intermediate sizes are silently dropped. This is by design and means you should not assume your callback fires at every pixel increment.
Safari contentBoxSize availability
Safari shipped contentBoxSize and borderBoxSize in 15.4. For earlier Safari versions, fall back to entry.contentRect, which is available everywhere. Keep the fallback in place if your support matrix includes Safari < 15.4.
display: contents and pseudo-elements
Elements with display: contents have no layout box; observing them produces no entries. Pseudo-elements (::before, ::after) cannot be observed at all — observe their parent instead.
Framework Integration Patterns
React hook
import { useEffect, useRef, useCallback } from "react";
interface UseBorderBoxSizeOptions {
box?: ResizeObserverBoxOptions;
onResize: (width: number, height: number) => void;
}
export function useBorderBoxSize<T extends Element>(
{ box = "border-box", onResize }: UseBorderBoxSizeOptions
): React.RefObject<T> {
const ref = useRef<T>(null);
// Stable reference to avoid re-registering on every render
const onResizeRef = useRef(onResize);
onResizeRef.current = onResize;
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const [size] = entry.borderBoxSize;
requestAnimationFrame(() => {
onResizeRef.current(
size?.inlineSize ?? entry.contentRect.width,
size?.blockSize ?? entry.contentRect.height
);
});
});
observer.observe(el, { box });
return () => observer.disconnect();
// 'box' is intentionally in the dep array — changing box model recreates the observer
}, [box]);
return ref;
}
// Usage
// const containerRef = useBorderBoxSize<HTMLDivElement>({
// onResize: (w, h) => setDimensions({ w, h }),
// });
// <div ref={containerRef} />
Note: useLayoutEffect is intentionally avoided here because ResizeObserver's async delivery means there is no synchronous DOM measurement that requires blocking paint. useEffect is sufficient and avoids SSR hydration warnings.
Vue 3 composable
import { ref, onMounted, onUnmounted, type Ref } from "vue";
export function useBorderBoxSize(
box: ResizeObserverBoxOptions = "border-box"
): {
targetRef: Ref<HTMLElement | null>;
width: Ref<number>;
height: Ref<number>;
} {
const targetRef = ref<HTMLElement | null>(null);
const width = ref(0);
const height = ref(0);
let observer: ResizeObserver | null = null;
onMounted(() => {
if (!targetRef.value) return;
observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const [size] = entry.borderBoxSize;
requestAnimationFrame(() => {
width.value = size?.inlineSize ?? entry.contentRect.width;
height.value = size?.blockSize ?? entry.contentRect.height;
});
});
observer.observe(targetRef.value, { box });
});
onUnmounted(() => {
observer?.disconnect();
observer = null;
});
return { targetRef, width, height };
}
// Usage in <script setup>:
// const { targetRef, width, height } = useBorderBoxSize();
// <div ref="targetRef">px wide</div>
Angular directive
import {
Directive, ElementRef, EventEmitter,
Input, OnDestroy, OnInit, Output
} from "@angular/core";
@Directive({ selector: "[appResizeObserver]", standalone: true })
export class ResizeObserverDirective implements OnInit, OnDestroy {
@Input() box: ResizeObserverBoxOptions = "border-box";
@Output() resized = new EventEmitter<{ width: number; height: number }>();
private observer: ResizeObserver | null = null;
constructor(private el: ElementRef<HTMLElement>) {}
ngOnInit(): void {
this.observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const [size] = entry.borderBoxSize;
requestAnimationFrame(() => {
this.resized.emit({
width: size?.inlineSize ?? entry.contentRect.width,
height: size?.blockSize ?? entry.contentRect.height,
});
});
});
this.observer.observe(this.el.nativeElement, { box: this.box });
}
ngOnDestroy(): void {
this.observer?.disconnect();
this.observer = null;
}
}
// Usage: <div appResizeObserver (resized)="onResize($event)"></div>
Configuration Variants
| Scenario | box option |
Key consideration |
|---|---|---|
| Text-flow container that adjusts line-height | content-box |
Padding changes do not trigger spurious callbacks |
| Drag-to-resize panel | border-box |
Reflects the full rendered footprint the user sees |
HiDPI canvas (<canvas>) |
device-pixel-content-box |
Gives physical pixel count; Chrome 84+ only |
| Container query JS polyfill | content-box |
Must match CSS contain: inline-size measurement |
| Dashboard card with thick border | border-box |
Prevents mismatch between JS measurement and CSS layout |
Debugging Checklist
Callback not firing?
- Confirm the element is in the DOM at the time
observe() - Check that the element is not
display: none - Verify the
boxoption matches the CSSbox-sizingof the target. A mismatch means changes to padding only appear underborder-box - In Chrome DevTools, open the Performance panel, record a resize, and search the flame chart for
ResizeObserver
"ResizeObserver loop limit exceeded" in console?
Run this reproduction script in the browser console to isolate the cause:
// Reproduction: this intentionally triggers the loop-limit error
const box = document.createElement("div");
document.body.appendChild(box);
const ro = new ResizeObserver((entries) => {
// Synchronous DOM write in the same frame — causes the error
box.style.width = entries[0].contentRect.width + 1 + "px";
});
ro.observe(box);
// Fix: wrap the style mutation in requestAnimationFrame(() => { ... })
Entries appear but values are stale?
contentRectrounds to CSS pixels. UseborderBoxSize[0].inlineSizefor fractional values.- If you are reading
getBoundingClientRect()inside the callback, you are forcing a reflow. Read from the entry instead.
Memory growing after component unmount?
- Confirm
disconnect()orunobserve()is called in the cleanup function. Check the Memory panel in DevTools: take a heap snapshot, filter byResizeObserver, and confirm no live instances remain after the component unmounts.
For deeper patterns on preventing detached-node accumulation, see Preventing memory leaks in long-running observers.
FAQ
Does ResizeObserver fire when transform: scale() changes?
No. CSS transforms are applied on the compositor thread after layout. They scale the painted output of an element without altering the layout-box dimensions that ResizeObserver tracks. If you need to respond to visual scale changes, you must track the transform value in JavaScript separately.
What causes the "ResizeObserver loop limit exceeded" error?
The error fires when a callback synchronously mutates the observed element's size within the same rendering update, creating a measurement loop the browser cannot resolve before the frame deadline. The browser caps the re-observation depth and emits the error as a warning rather than throwing. Fix: wrap DOM writes in requestAnimationFrame(() => { ... }) to defer them to the next frame.
Is the callback synchronous or asynchronous?
Asynchronous, but not a microtask and not a macrotask. The HTML specification places ResizeObserver callback delivery inside the "update the rendering" steps — specifically after layout and before paint. Multiple size changes within one task are batched; you receive one callback per element per frame regardless of how many mutations occurred. This is why you cannot use it as a drop-in for synchronous measurement APIs like getBoundingClientRect().
Can I observe the same element with multiple ResizeObserver instances?
Yes, and the browser will fire callbacks from all instances when the element resizes. However, this multiplies callback overhead and makes cleanup harder to coordinate. The preferred pattern is one shared observer instance with a WeakMap that maps elements to their individual callbacks, as shown in Step 1 above.
Does ResizeObserver fire immediately on the initial observe() call?
Yes, always. Calling observe() enqueues one initial entry with the element's current dimensions, delivered in the next rendering update. This is the reliable, reflow-free way to read an element's baseline size — no need for a separate getBoundingClientRect() call on mount.
Related
- Element Resize Detection Patterns — hierarchical observer delegation and container-level tracking
- Detecting Container Queries with ResizeObserver — using ResizeObserver as a JS-side container-query engine
- Observer Lifecycle & Memory Management — WeakMap registries, disconnect discipline, and SSR hydration guards
- Syncing Observer Callbacks with requestAnimationFrame — rAF scheduling patterns shared across the observer family
- Browser Compatibility & Polyfills — support matrix and graceful degradation