ResizeObserver callbacks run inside the rendering steps — after layout, before paint — and the browser repeats the gather-and-deliver cycle, deeper in the tree each time, until nothing changes or it gives up.

Problem / Scenario Context

A dashboard card component resizes its chart whenever the card changes size. The chart then writes a new height back to the card so the legend fits. In most layouts this settles immediately. In a nested grid, the console fills with ResizeObserver loop completed with undelivered notifications and the legend occasionally jumps a frame late.

To fix it you need to know how the browser runs these callbacks, which is different from every other observer. The Rendering Pipeline & Observer Timing topic compares all four families; this page zooms into the resize loop.

Mechanics Explanation

The Resize Observer specification adds a small loop to the HTML update the rendering steps, right after style and layout are brought up to date:

  1. Set depth to 0.
  2. Gather active observations at depth. For every observed element, compare its current box size (in the box model you asked for) with the last size reported. Elements that changed and sit deeper in the DOM tree than the current depth are active; changed elements at or above the depth are skipped and remembered.
  3. While there are active observations: broadcast them — call every observer's callback with its entries, which updates the "last reported size" — then set depth to the shallowest element just delivered, recalculate style and layout, and gather again.
  4. If skipped observations remain, fire an error event with the "loop completed with undelivered notifications" message. The skipped elements will be delivered next frame.

The depth rule is the loop guard. Each iteration can only deliver elements deeper than the shallowest element of the previous iteration, so the process must terminate — the tree has finite depth. A callback that resizes an ancestor of something it just measured is exactly the case the rule pushes to the next frame.

The Resize Loop Inside One FrameA vertical sequence of steps. Layout completes. Observations deeper than the current depth are gathered. Callbacks are broadcast. Depth moves to the shallowest element delivered and layout is recalculated. If more deeper observations are active, the loop repeats; if only shallower ones remain, they are skipped and the error event fires, and they are delivered next frame.1Style and layoutGeometry is brought up to date for the frame.2Gather at depthChanged elements deeper than the current depth become active; shallower changesare skipped.3BroadcastEvery observer with active entries is called; last-reported sizes are updated.4Re-layout, raise depthDepth becomes the shallowest delivered element; layout reruns if callbacks wrote.5Skipped remain?Fire the loop-limit error and deliver the skipped elements next frame.

Because the callback runs after layout and before paint, a write made inside it — setting a canvas size, adding a class — is painted in the same frame. That is the property that makes ResizeObserver the right tool for measure-then-adjust work and the reason canvas resizing is flicker-free.

Comparison Table: What a Callback Write Does

Write inside the callback Affects Result
Size of a descendant of the observed element deeper element delivered in the same frame, next iteration
Size of the observed element itself same depth skipped; loop error; delivered next frame
Size of an ancestor shallower element skipped; loop error; delivered next frame
Non-layout style (colour, transform) nothing observable no further iteration
Canvas backing-store size (canvas.width) not a CSS box change no further iteration

Minimal Reproducible Example

TypeScript
// A card that sets its own height from its width — observed element writes to itself.
const card = document.querySelector<HTMLElement>('.card')!;

const ro = new ResizeObserver(([entry]) => {
  const w = entry.contentBoxSize[0].inlineSize;
  card.style.height = `${Math.round(w * 0.6) + 1}px`;   // +1 guarantees a new size each pass
});
ro.observe(card);

window.addEventListener('error', (e) => {
  if (e.message.includes('ResizeObserver loop')) console.warn('loop guard tripped', performance.now());
});

Resize the window and the warning fires every frame. Height is part of the content box, so writing it changes the very element being observed at the depth that was just delivered.

Writes That Settle vs Writes That LoopTwo columns. Writes that settle in one pass include resizing a child element, changing a canvas backing store, and toggling a class that only changes colour. Writes that trip the loop guard include changing the observed element's own content height, resizing a parent, and toggling a class that changes padding on the observed element.Settles in the same frameResize a child of the observed elementSet canvas.width and canvas.heightToggle a class that only changes colour or transformWrite aspect-ratio in CSS instead of height in JSTrips the loop guardSet the observed element's own heightResize an ancestor to fit the contentToggle a class that changes padding or borderWrite a size that changes every pass, such as w + 1

Production-Safe Solution

Most loops disappear once the callback writes somewhere deeper, or lets CSS express the relationship. When a self-referential write is unavoidable, make it idempotent so the second pass sees no change.

TypeScript
interface SizeRule {
  target: HTMLElement;
  compute: (inline: number) => number;   // desired block size from inline size
}

export function applyAspectRule({ target, compute }: SizeRule): () => void {
  let lastApplied = -1;

  const ro = new ResizeObserver(([entry]) => {
    const inline = entry.contentBoxSize[0].inlineSize;
    const desired = Math.round(compute(inline));
    // Idempotent: writing the same value does not change the box, so no new observation.
    if (desired === lastApplied) return;
    lastApplied = desired;
    target.style.blockSize = `${desired}px`;
  });

  ro.observe(target, { box: 'content-box' });
  return () => ro.disconnect();
}

// Better still, when the rule is a pure ratio, remove the observer entirely:
// .card { aspect-ratio: 5 / 3; }

Writing the block size changes the content box once; on the next pass the inline size is unchanged, desired is unchanged, and the callback returns without writing. The loop settles in a single extra iteration and never trips the guard. The broader toolkit for this error is in fixing "ResizeObserver loop limit exceeded".

Iterations per Frame With and Without an Idempotent WriteA line chart of loop iterations per frame across a window drag of twenty frames. The naive self-write stays pinned at the maximum and trips the guard every frame. The idempotent write settles at two iterations per frame and drops to one once the window stops moving.01234048121620frame number during a window dragiterations per framenaive self-writeidempotent write

Edge Cases: Depth, Box Models and Rounding

Depth is DOM depth, not visual nesting. An absolutely positioned overlay that visually covers its parent's sibling is still judged by where it sits in the tree. A tooltip portalled to document.body is shallow — depth 1 or 2 — so resizing it from a callback that just measured a deeply nested trigger element will be skipped to the next frame. If a portalled element must be sized in the same frame, observe something that sits below it, or size it with CSS anchored to custom properties the callback writes on the tooltip itself.

The box you observe decides what counts as a change. Observing content-box (the default) ignores padding and border changes entirely; observing border-box sees them. A callback that toggles a class adding padding is harmless under content-box and loops under border-box. Choose the box that matches what the callback reads, and write only properties outside it where you can.

Fractional sizes and zoom. Box sizes are reported as floating-point CSS pixels. At 110% or 125% browser zoom, a value you write as an integer can be laid out as a fraction, reported back as a fraction, and compared unequal to the integer you stored. Compare with a tolerance — Math.abs(a - b) < 0.5 — rather than strict equality when deciding whether your write already took effect.

Observing many elements multiplies iterations only when they are nested. Hundreds of sibling cards at the same depth are delivered in one broadcast. Nesting observed elements three levels deep can take three iterations when each level writes to its children. Flatten the observed set when a component tree observes at every level.

Verification Steps

  • Listen for the error event with the snippet above and confirm it stays silent while you drag-resize the window.
  • Count callback invocations per frame by stamping them with a rAF frame counter; two per frame during a resize is normal, more is a loop.
  • Open the Performance panel and check that Layout appears at most twice inside the rendering steps of a resize frame.
  • Test inside a nested grid, which is where depth ordering produces the most iterations.

Common Mistakes to Avoid

  • Silencing the error with a global handler. It hides real skipped notifications, which surface later as a component that is one frame out of date.
  • Wrapping the write in requestAnimationFrame by reflex. It removes the error by moving the write to the next frame, which reintroduces a one-frame flash — the thing the resize observer was avoiding.
  • Observing border-box and writing padding. Padding is part of the border box, so the write always changes the observed size.
  • Rounding differently in read and write. Reading a fractional inline size and writing a rounded block size is fine; reading rounded and writing fractional can alternate forever.

FAQ

Why does the browser call my ResizeObserver twice in one frame?

Because something written during the first broadcast changed the size of a deeper observed element, and the loop gathered it on the next iteration. That is by design and costs one extra layout. It only becomes a problem when the write targets the same or a shallower element.

Is the loop-limit error dangerous?

It is not a crash. It means some notifications were skipped this frame and will be delivered next frame. The visible effect is a component that lags one frame, and in test runners the error event can fail a test that treats window errors as fatal.

Does ResizeObserver fire before or after requestAnimationFrame callbacks?

After. rAF callbacks run first in the rendering steps, then style and layout, then the resize loop. A size you write in rAF is therefore seen by ResizeObserver in the same frame.

What depth does the loop start from?

Zero, which is shallower than every element, so on the first iteration every changed observation is active. Depth only matters from the second iteration onward.


↑ Back to Rendering Pipeline & Observer Timing