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.

ResizeObserver delivery in the browser frame lifecycle A horizontal swimlane diagram contrasting the legacy window.resize path (fires as a macrotask, may trigger forced reflow) with the ResizeObserver path (fires after layout, before paint, inside the rendering update steps). FRAME N Task queue Rendering update Compositor JS Tasks / Microtasks Style Layout ResizeObserver callback fires Paint Composite window.resize (macrotask, unpredictable) ResizeObserver (spec-guaranteed safe read window) Legacy resize event

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
// 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

TypeScript
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.

TypeScript
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.

TypeScript
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

TypeScript
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:

TypeScript
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

TypeScript
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

TypeScript
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

TypeScript
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 box option matches the CSS box-sizing of the target. A mismatch means changes to padding only appear under border-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:

JavaScript
// 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?

  • contentRect rounds to CSS pixels. Use borderBoxSize[0].inlineSize for 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() or unobserve() is called in the cleanup function. Check the Memory panel in DevTools: take a heap snapshot, filter by ResizeObserver, 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.


↑ Back to Core Observer Fundamentals & Browser APIs