skip to content

A page logs 'ResizeObserver loop completed with undelivered notifications' to the console, and the CI test runner fails the build because it treats it as an uncaught error. What produces that message, and how would you fix the code causing it?

level: seniorimportance: should knowfreq 50%

answer

  1. the callback changed what it was measuring
  2. after layout, before paint
  3. not a throw — an event on window
  4. deferred to the next frame, not lost

basics

~20 s

A ResizeObserver callback changed something that resized an observed element in the same frame, so the browser re-ran layout and re-delivered repeatedly. When it cannot settle, it stops, defers the remaining notifications to the next frame, and fires an error event on window. Fix the feedback, don't silence it.

solid answer

~50 s

ResizeObserver callbacks run inside the browser's rendering update, after layout and before paint. If a callback writes something that changes the size of an observed element, that creates a new active observation in the same frame, so the browser re-runs layout and delivers again. The algorithm limits how many times it will do that; when observations are still pending after the loop ends it stops, leaves them for the next frame, and fires an `error` event on `window` with that message. Nothing is lost and no exception is thrown inside your callback — which is exactly why CI runners and dev-server overlays that watch `window` errors fail on it. The real fix is to break the feedback: observe the container and write to a child instead of the observed element, make the write idempotent so an unchanged value produces no write at all, or defer the write to the next frame with `requestAnimationFrame`.

code

javascript · 12 lines
javascript
const box = document.body.appendChild(document.createElement('div'));
box.style.width = '400px';

let applied = null;
const ro = new ResizeObserver(([entry]) => {
  const width = Math.round(entry.contentBoxSize[0].inlineSize);
  const mode = width < 480 ? 'narrow' : 'wide';
  if (mode === applied) return;        // idempotent: no write, no new observation
  applied = mode;
  entry.target.dataset.mode = mode;    // an attribute, not a size
});
ro.observe(box);

go deeper

for a junior

Know that the message means a ResizeObserver callback resized something it was observing, and that the first thing to check is whether the callback writes width, height, or padding onto the element it just measured.

for a middle

Explain the mechanism: callbacks run after layout and before paint, a size-changing write creates a new active observation in the same frame, and the browser gives up after its loop guard trips and defers to the next frame.

for a senior

Diagnose it without a stack — the error arrives as a window event — and choose a real fix: restructure so the write cannot affect the observed box, guard on a coarse idempotent value, or defer with requestAnimationFrame and state its one-frame cost.

for a principal

Decide the policy: whether size-mirroring in JavaScript is allowed in the component library at all, whether the noise is filtered from error reporting and under what ticket, and how the codebase prevents the next component from reintroducing the cycle.

## Where ResizeObserver sits in a frame Every frame, the browser runs a rendering update: it computes layout, runs `requestAnimationFrame` callbacks, delivers resize observations, and eventually paints. ResizeObserver delivery is inside that sequence, after layout has been computed and before the frame is painted. That placement is what makes the API useful — the sizes you receive are current and a style you write can still land in this frame — and it is what makes the loop possible. ## The loop After your callback returns, the browser checks whether anything is now out of date. If your callback wrote a style that changed the size of an element under observation, that element has a new **active observation**, so the browser re-runs layout and delivers again. It repeats. To guarantee termination, the algorithm is depth-based: each pass only delivers observations for elements deeper in the tree than the shallowest depth delivered in the previous pass. That is what makes the common, benign cascade terminate — a parent resizes, its callback sizes a child, the child's callback sizes a grandchild, and the depth strictly increases until the tree runs out. What does not terminate is a **cycle at the same depth**: an element whose own callback changes its own size, or two siblings that size each other. Depth never increases, so the loop guard trips. The browser stops looping, leaves the pending observations for the next frame's delivery, and reports the situation by firing an `error` event at the window with the message *"ResizeObserver loop completed with undelivered notifications."* Older Chrome versions worded the same condition as *"ResizeObserver loop limit exceeded"*. ## Why it breaks CI and not the page This is not an exception thrown out of your callback — there is no stack pointing at your code, and a `try`/`catch` around the callback body catches nothing. It arrives as an error event on `window`, which means: - `window.onerror` and `window.addEventListener('error', ...)` see it, usually with no useful filename or line; - error-reporting SDKs record it, often in volume; - test runners and dev-server error overlays that treat any window error as a failure will fail the run. For the user, the visible symptom is milder: the reaction to a size change lands one frame late, which can look like a one-frame flicker or a slight lag when dragging. If the cycle re-arms every frame, you get a permanent extra frame of latency and a steady stream of errors. ## Finding the cause The error carries no stack, so work from the observers you have. The cycle is always "callback writes X → X changes the size of something observed". Look for callbacks that set `width`, `height`, `padding`, `border` or `font-size` on the element they are measuring, or that insert content into it. A quick bisect — comment out one observer's write, see if the message stops — is usually faster than reasoning about it. ## Fixing it **1. Stop feeding back.** The cleanest structural fix is to observe one element and write to a different one that cannot affect the observed box: observe the container, size the child. If the write cannot change the observed element's box, no new observation is generated. **2. Make the write idempotent.** Track the last value you applied and return early when it has not changed. Snapping to a coarse value — a breakpoint name, a rounded pixel count — makes this far more likely to converge than mirroring a fractional size, because sub-pixel jitter alone can keep a cycle alive. **3. Write a non-layout output.** Setting a `data-*` attribute or a class that only changes colour cannot resize anything. Reserve size writes for cases where they are genuinely required. **4. Defer to the next frame.** Wrapping the write in `requestAnimationFrame` moves it out of the current rendering update — `rAF` callbacks for this frame have already run — so the write lands in the next frame's layout and the loop cannot form. The cost is one frame of latency and the possibility of a visible flicker, so treat it as a pragmatic fix rather than the first one. **5. Match the observed box to what you write.** If your callback adjusts padding or border, observing the border box means those adjustments do not register as a change at all. ## What not to do Adding a global handler that swallows exactly this message is a stopgap teams sometimes ship to unblock CI. It is honest only if it is paired with a ticket: the underlying cycle still costs a frame every time it happens, and once the message is filtered nobody will notice the next component that introduces one.

  • Does wrapping the callback body in try/catch stop the message?
    No. The loop condition is detected by the browser after your callback returns, and it is reported as an `error` event on `window`, not as an exception propagating out of your function. A `try`/`catch` inside the callback never sees it. The only things that stop it are removing the feedback or suppressing the window error, and only the first is a fix.
  • Are the undelivered notifications lost?
    No — they are delivered in the next frame. That is why the page still works and the symptom is a one-frame lag rather than a stuck layout. It also means the cycle can re-arm immediately: if next frame's callback writes the same feedback again, you get the error every frame along with permanent extra latency.
  • Why does a parent-to-child sizing cascade usually not trigger this?
    The delivery algorithm is depth-based: each pass only re-delivers observations deeper in the tree than the shallowest depth handled previously. A cascade that always moves downward has strictly increasing depth, so it terminates naturally. The guard trips on cycles at the same depth — an element resizing itself, or two siblings resizing each other.
  • When is requestAnimationFrame an acceptable fix rather than a hack?
    When the write genuinely must change the observed box and no idempotent guard converges — for example positioning that depends on the post-write layout. Deferring costs one frame, which can show as a flicker on first paint, so pair it with a guard that skips the write when the value is unchanged, or the deferred write simply re-arms the cycle on a slower clock.

It is a thermostat mounted on the heater it controls: every reading changes the thing being read, so the system chases itself instead of settling.

saying these in an interview costs you the question

  • Calls the message a browser bug with no application cause
  • Wraps the callback in try/catch and expects it to disappear
  • Says the notifications are discarded and sizes are lost
  • Claims rendering is blocked or the page is broken
  • Suppresses the window error handler and calls it fixed

context