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.

The feedback cycle that produces the loop error A resize notification triggers a style write that changes the observed box, which schedules another notification within the same frame until the browser stops delivering them. layout computed observed box changed callback runs writes padding + font-size layout invalidated same element, new box notification queued depth counter + 1 depth limit reached error reported, rest deferred

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:

Debugging Pitfall — silencing the error instead of fixing it. The widely copied fix is a global 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.

Four ways to break the resize feedback cycle Comparison of writing to a descendant, deferring to the next animation frame, quantising the written value, and replacing the observer with container queries. Write deeper observe wrapper, style a child depth rule permits it cost: one extra node best default Defer a frame requestAnimationFrame around the write loop cannot form cost: one frame of lag good for rare writes Quantise snap to breakpoints, skip no-op writes oscillation damped cost: coarser output pairs with the others Container query no JS at all engine handles convergence cost: CSS-only logic use when it fits

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.

Which observed box to choose, and which writes are safe against it Content-box observation is safe against border and padding writes, border-box observation is not, and device-pixel-content-box is the correct choice for canvas backing stores. box option → which self-writes are safe content-box measured area padding writes: safe border writes: safe width writes: unsafe default for text fitting border-box measured area padding writes: unsafe border writes: unsafe use when spacing matters write to a descendant only device-pixel-content-box exact device pixels canvas backing store no resampling blur survives zoom changes write to the canvas, not the box

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.