ResizeObserver Loop Errors in Components: Diagnosing “loop completed with undelivered notifications”
Two error strings account for nearly every ResizeObserver complaint in a browser console: ResizeObserver loop limit exceeded in older Chromium, and ResizeObserver loop completed with undelivered notifications in current engines. Both mean the same thing. Your callback changed layout, that change resized an observed element, the browser queued another round of notifications, and after a bounded number of rounds within a single frame it gave up and deferred the rest to the next frame — reporting the fact as an uncaught error.
The error is a warning about a rendering stall, not a crash: the observation is not lost, and the layout usually settles one frame later. But it is reported through window.onerror, so it poisons error-tracking dashboards, fails Playwright and Cypress runs that treat console errors as failures, and — when the loop truly does not converge — pins a component in a permanent resize cycle that burns a core. This deep dive sits under Observer Teardown & Memory Safety in Core Architecture & Lifecycle Management.
Problem Statement: The Self-Resizing Label
class FitLabel extends HTMLElement {
#observer = null;
connectedCallback() {
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>:host { display: block; } span { white-space: nowrap; }</style>
<span><slot></slot></span>`;
this.#observer = new ResizeObserver(([entry]) => {
const span = this.shadowRoot.querySelector('span');
const available = entry.contentRect.width;
// BUG: writing a style that changes this element's own observed box.
span.style.fontSize = `${Math.max(12, Math.min(24, available / 12))}px`;
this.style.paddingBlock = span.getBoundingClientRect().height > 30 ? '0.5rem' : '0.25rem';
});
this.#observer.observe(this);
}
disconnectedCallback() {
this.#observer?.disconnect();
this.#observer = null;
}
}
customElements.define('fit-label', FitLabel);
<fit-label style="width: 240px">Framework-agnostic composition</fit-label>
Resize the window slowly and the console fills with ResizeObserver loop completed with undelivered notifications. The padding write changes the element’s content box, which is the box being observed, which schedules another notification, which writes padding again. Sometimes it converges after two rounds; at certain widths the font size oscillates between two values and it never does.
Root-Cause Analysis
The Resize Observer specification defines delivery in terms of the rendering steps of the event loop. After style and layout, the browser runs gather active observations at a given depth, invokes the callbacks, and then checks whether the callbacks produced new active observations. If they did, it repeats — but only for observations whose target is deeper in the DOM than the shallowest element processed in the previous round. That depth rule is what makes the common parent-to-child cascade terminate: a callback resizing a descendant is legal and settles.
When callbacks keep producing observations that are not deeper — because the callback resized the very element it observed, or a sibling, or an ancestor — the loop cannot make progress by the depth rule. The specification then says: deliver nothing further this frame, fire an error event on the window with the message ResizeObserver loop completed with undelivered notifications, and schedule the remaining notifications for the next frame.
Three practical corollaries:
- The error is informational when layout converges. A single spurious error at startup, from a component that measures once and settles, is harmless noise. It is still worth eliminating because it is indistinguishable from the pathological case in an error dashboard.
- Writing to a descendant is safe; writing to yourself is not. The depth rule explicitly accommodates parent-observes-parent-writes-child. Observe a wrapper and write to its child, not the other way around.
- The observed box matters.
contentRectand thecontent-boxoption exclude padding and border, so writingpaddingchanges the border box without changing the content box — unless the element’s size depends on its content, which is exactly the case for adisplay: blockelement withbox-sizing: content-boxand auto height. Choosing{ box: 'border-box' }or'device-pixel-content-box'changes which writes are self-referential.
error handler that swallows any message starting with "ResizeObserver loop". It does make CI green. It also hides genuine non-convergent loops that pin a CPU core, and it suppresses the signal for every other component on the page. Silence the message only after you have confirmed the layout settles — and scope the suppression to the test runner, never to production code.
There is one more trigger worth naming because it is not a bug in your callback at all: scrollbars. An element that grows past its container’s height gains a vertical scrollbar, which narrows the content box, which may shrink the content enough to remove the scrollbar. That oscillation is a genuine layout instability and produces the same error. scrollbar-gutter: stable removes it in one line.
Production-Safe Fix
The corrected component observes a wrapper, writes only to a descendant, quantises its output so tiny fluctuations produce no write at all, and skips writes that would not change anything.
const STEPS = [12, 14, 16, 18, 20, 24];
const quantise = (px) => STEPS.reduce((best, step) => (step <= px ? step : best), STEPS[0]);
class FitLabel extends HTMLElement {
#observer = null;
#lastStep = 0;
connectedCallback() {
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; }
/* The observed element. Padding lives here and never changes. */
.frame { display: block; padding-block: 0.35rem; }
/* The written element: strictly deeper than the observed one. */
.text { white-space: nowrap; display: block; }
</style>
<div class="frame"><span class="text"><slot></slot></span></div>`;
const frame = this.shadowRoot.querySelector('.frame');
const text = this.shadowRoot.querySelector('.text');
this.#observer = new ResizeObserver((entries) => {
// A detached target still delivers one final record.
if (!this.isConnected) return;
const width = entries[0].contentBoxSize?.[0]?.inlineSize ?? entries[0].contentRect.width;
const step = quantise(width / 12);
// Skip the write entirely when nothing changed: no write, no new observation.
if (step === this.#lastStep) return;
this.#lastStep = step;
text.style.fontSize = `${step}px`;
});
// Observe the wrapper's content box, not the host.
this.#observer.observe(frame, { box: 'content-box' });
}
disconnectedCallback() {
this.#observer?.disconnect();
this.#observer = null;
this.#lastStep = 0;
}
}
customElements.define('fit-label', FitLabel);
Three defences, each sufficient on its own and cheap enough to apply together. The write targets .text, which is deeper than the observed .frame, so the depth rule allows the resulting observation to be delivered in the same frame. The step function means widths of 239px and 241px produce the same font size, so ordinary sub-pixel jitter generates no write. And the #lastStep guard turns a repeated identical result into a no-op, which is the difference between “converges after two rounds” and “produces no second round at all”.
Where the goal is purely presentational, the better answer is often to delete the observer. A @container rule expresses the same intent declaratively and the engine guarantees convergence — see container queries in components.
Verification
// 1. Assert the error never fires while exercising the component.
const seen = [];
window.addEventListener('error', (e) => {
if (String(e.message).includes('ResizeObserver loop')) seen.push(e.message);
});
const label = document.querySelector('fit-label');
for (const width of [200, 260, 320, 261, 259, 480]) {
label.style.width = `${width}px`;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}
console.assert(seen.length === 0, `expected no loop errors, saw ${seen.length}`);
// 2. Assert the quantiser actually suppresses writes.
const text = label.shadowRoot.querySelector('.text');
label.style.width = '240px';
await new Promise((r) => requestAnimationFrame(r));
const before = text.style.fontSize;
label.style.width = '242px';
await new Promise((r) => requestAnimationFrame(r));
console.assert(text.style.fontSize === before, 'sub-step change must not write');
In DevTools, the Performance panel is the decisive tool for the non-convergent case. Record five seconds of an idle page: a healthy component shows nothing, while a looping one shows a repeating Layout → Recalculate Style → Layout band on every frame with no user input. Expanding one band shows the observer callback as the initiator, naming the component directly.
When to Use vs When to Avoid
| Situation | Approach |
|---|---|
| Purely visual response to available width | Container queries — no observer, guaranteed convergence |
| Need the measured pixel value in JavaScript | ResizeObserver, writing only to descendants |
| Canvas or WebGL surface sizing | ResizeObserver with device-pixel-content-box |
| Element must resize itself from its own size | Redesign — this is the loop, not a use case |
| Scrollbar appears and disappears at a threshold | scrollbar-gutter: stable, no script |
| Third-party component emits the error | Scope suppression to tests only, and file a bug |
The fourth row is worth being blunt about. “Resize this element based on this element’s size” has no convergent solution in the general case; the fix is always to introduce a second element so that one measures and the other changes. Adding a wrapper is not a workaround, it is the shape the API expects.
Frequently Asked Questions
Is the loop error safe to ignore?
Functionally, usually — the deferred notifications are delivered on the next frame, so layout settles. Operationally, no: it is reported through window.onerror, so it fails strict test runners and pollutes error dashboards, and it is indistinguishable from a genuinely non-convergent loop.
Why does writing to a child element not trigger the error?
The specification’s depth rule permits a round of notifications whose targets are deeper than the previous round’s shallowest element. Parent-observes, child-writes therefore makes progress and terminates; self-writes and sibling-writes do not.
Does debouncing the callback fix it?
Deferring the write to the next animation frame does break the cycle, at the cost of one frame of visible lag. It is a reasonable fix for infrequent writes and a poor one for anything that must track a drag, where writing to a descendant is better.
Which box option should I observe?
content-box for text-fitting logic, border-box when padding or borders participate in what you are measuring, and device-pixel-content-box for canvas backing stores, where you need the exact device pixel count to avoid resampling.
Related
- Observer Teardown & Memory Safety — the parent topic on observer lifetimes.
- Core Architecture & Lifecycle Management — the section this deep dive belongs to.
- Disconnecting Observers in disconnectedCallback — the teardown half of the same observer.
- Container Queries in Components — the declarative alternative that cannot loop.
- Optimizing Style Recalculation in Large Component Trees — measuring the layout cost this error signals.