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:
- Set depth to 0.
- 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.
- 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.
- If skipped observations remain, fire an
errorevent 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.
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
// 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.
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.
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".
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
requestAnimationFrameby 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-boxand 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.
Related
- Where IntersectionObserver Callbacks Run in the Event Loop — the post-paint alternative
- MutationObserver Microtask Timing Explained — the pre-layout observer
- ResizeObserver Mechanics & Triggers — what counts as a size change
↑ Back to Rendering Pipeline & Observer Timing