A ResizeObserver test is rarely really about the observer itself. It is about proving that a component reacts to a size change at the right time, with the right DOM state, and without creating a feedback loop that keeps re-rendering as the layout changes.

That distinction matters because ResizeObserver callbacks are delivered after layout, not whenever your test decides to inspect the page. If your assertions run too early, you get timing flakes. If your component writes state from the observed size without a guard, you can create a render-observe-render loop that only shows up under real browser timing.

The safest way to test ResizeObserver-driven UI updates is to use a real browser, change the container size in a controlled way, wait for the layout to settle, and assert on the final visible state. For loop detection, add a test that changes size once and verifies the callback does not keep emitting updates for the same settled geometry.

What ResizeObserver does, and why tests get flaky

ResizeObserver reports element size changes after layout has happened. It is not a synthetic event you dispatch like a click. In browsers, the observer callback runs as part of the rendering pipeline, which means there can be a small delay between changing width or content and seeing the observer-driven UI update.

The official references are useful here:

If a test reads the DOM immediately after changing size, it may be asserting against the pre-observer state, not the settled UI.

That is the core source of layout measurement flakiness. The test did not fail because the UI is wrong, it failed because the assertion raced the browser.

The failure modes worth testing for

A good test suite for ResizeObserver-driven UI updates should cover three separate risks.

1) The UI never updates after a size change

Example: a chart, toolbar, card grid, or sidebar calculates visible items from its container width. The component updates only when ResizeObserver fires, so the test must verify the visible layout changes after a size mutation.

2) The UI updates, but too early or too late

If the component depends on state updates triggered inside the observer callback, the DOM may go through an intermediate state. Your test should wait for the final state, not the first sign of movement.

3) The callback causes a hidden re-render loop

This is the subtle one. If the callback sets state that changes the observed element’s size, the observer can fire again. Sometimes that loop is benign and stops on the next layout. Sometimes it becomes a noisy churn that only appears in certain viewport sizes or with certain fonts.

A test should make this visible by checking that one intentional resize leads to one settled result, not repeated changes for the same geometry.

Prefer a real browser over mocking ResizeObserver

You can mock ResizeObserver in unit tests, but that only proves your callback wiring. It does not prove browser timing, layout, or rendering order.

Mocking is useful for pure logic, for example, mapping measured width to a breakpoint label. It is weak for integration behavior because it skips the browser event loop and the actual resize pipeline.

For ResizeObserver testing in browser automation, use the browser itself when the question is, “Does the UI update correctly after layout changes?” Use mocks only when the question is, “Does this pure function choose the right state for a given width?”

A reliable pattern: resize the container, then wait for the settled UI

The key is to change size in a controlled way and wait for the observable result, not a fixed timeout.

Here is a Playwright example that tests a component whose text changes when its container shrinks:

import { test, expect } from '@playwright/test';
test('updates after the container shrinks', async ({ page }) => {
  await page.goto('http://localhost:3000');

  const panel = page.locator('[data-testid="responsive-panel"]');
  await expect(panel).toHaveText(/Expanded/);

  await page.evaluate(() => {
    const el = document.querySelector('[data-testid="responsive-panel"]') as HTMLElement;
    el.style.width = '280px';
  });

  await expect(panel).toHaveText(/Compact/);
});

This works because the assertion waits for the final text, instead of assuming the observer callback has already finished.

Why this is better than waitForTimeout

Fixed sleeps make tests slower and less deterministic. They also hide the real timing contract. If the UI takes 5 ms one day and 120 ms the next, a waitForTimeout(100) is either wasteful or flaky.

Use assertion-based waiting or a browser state condition. In Playwright, toHaveText, toHaveAttribute, and toBeVisible all retry until the condition is met or the timeout expires.

Seed size changes in the same way the app does

The cleanest resize tests manipulate the same element the application observes, or a parent container that the component measures. That keeps the test close to the real behavior.

A few practical patterns:

  • Change the width of the observed element with inline style in the test
  • Wrap the component in a test harness container and resize the wrapper
  • If the component responds to viewport width, use page.setViewportSize(...)
  • If the component responds to parent layout, prefer container resizing over viewport resizing

Use the one that matches the code path. If the component reads its own container, resizing the browser viewport may not be enough.

Example with a wrapper element

await page.setContent(`
  <div id="shell" style="width: 500px;">
    <div data-testid="responsive-panel"></div>
  </div>
`);
await page.evaluate(() => {
  const shell = document.getElementById('shell') as HTMLElement;
  shell.style.width = '320px';
});
await expect(page.locator('[data-testid="responsive-panel"]')).toHaveText(/Compact/);

This is especially useful for responsive component rerenders that depend on parent width, not viewport width.

Wait for layout to settle, not just for a callback to fire

A ResizeObserver callback can schedule state updates, which trigger renders, which may change layout again. So the browser may need more than one frame to settle.

That means your test should assert on a stable end state, not a transient intermediate one. In practice, that usually means one of these:

  • Wait for the final label or class name
  • Wait for a count of visible items to stabilize
  • Wait for a DOM measurement to stop changing across two reads

Here is a small helper that checks stability by reading the same size twice:

async function waitForStableWidth(locator, page) {
  let previous = -1;
  for (let i = 0; i < 10; i++) {
    const current = await locator.evaluate(el => el.getBoundingClientRect().width);
    if (current === previous) return;
    previous = current;
    await page.waitForAnimationFrame();
  }
  throw new Error('Width never stabilized');
}

This is not a universal solution, but it is useful when the UI transitions through multiple renders and you need to avoid reading mid-settle geometry.

Detecting hidden re-render loops

A hidden loop is easier to catch if the component exposes a count, a log, or a visible class change that increments when a ResizeObserver callback runs. If you control the component, add a test-only hook behind a flag or a data attribute.

For example, if the callback updates state only when the measured width bucket changes, the test can verify the bucket changes once and then stays stable.

Example of a loop-focused assertion

await page.evaluate(() => {
  const el = document.querySelector('[data-testid="responsive-panel"]') as HTMLElement;
  el.style.width = '260px';
});
await expect(page.locator('[data-testid="resize-count"]')).toHaveText('1');

The exact signal depends on your implementation. The important part is that the test observes a monotonic counter or a final state, not just a single callback.

If changing width once produces multiple visible state changes, inspect whether the observer callback writes to measured layout unconditionally.

Common code smell inside the callback

A risky pattern looks like this:

const ro = new ResizeObserver(entries => {
  const width = entries[0].contentRect.width;
  setSize(width);
});

That is not always wrong, but it becomes fragile if setSize(width) changes the element size again. A safer approach is to derive coarse-grained state, such as breakpoint buckets, and bail out when the new bucket matches the current one.

const nextBucket = width < 400 ? 'compact' : 'regular';
if (nextBucket !== bucket) setBucket(nextBucket);

That guard is exactly the sort of behavior a test should validate.

What to assert, and what not to assert

For ResizeObserver-driven UI updates, prefer assertions that reflect user-visible outcomes:

  • text changes
  • class changes tied to layout state
  • visibility of condensed or expanded controls
  • number of items shown
  • presence of overflow affordances

Avoid overfitting to implementation details like internal state variables or callback invocation counts unless the loop risk is the thing you are specifically trying to detect.

If you can express the behavior in visible DOM terms, the test will usually be more durable.

A practical decision framework

Use browser automation when

  • the component depends on actual layout and rendering
  • the bug could involve timing, repaint, or reflow
  • you need to verify real browser behavior in Chromium, Firefox, or WebKit
  • the component combines ResizeObserver with CSS transitions, fonts, or async data

Use unit tests when

  • the resize logic is a pure mapping from width to state
  • you want fast checks for breakpoint math
  • the browser itself is not part of the behavior under test

Use both when

  • the breakpoint logic matters and the DOM update matters
  • you need confidence that the calculation and the layout reaction agree
  • re-render loops are a realistic regression risk

This split keeps browser automation focused on browser behavior, which is the part that unit tests cannot model faithfully.

A small checklist for stable ResizeObserver tests

  • Change the size of the element the component actually observes
  • Wait for a visible end state, not an arbitrary delay
  • Prefer real browser automation over mocking for layout behavior
  • Assert user-visible outcomes, not internal implementation details
  • Add one test that checks for stable settlement after a resize
  • If possible, expose a test-only signal for callback counts when loop detection matters

When a failing test points to a real bug

If a ResizeObserver test fails intermittently on the same browser and same fixture, do not assume the test is the only problem. Look for these implementation issues:

  • state updated on every callback without comparing to the previous bucket
  • DOM writes inside the observer that change the observed element size
  • measurement taken before fonts or transitions have settled
  • component logic that depends on viewport size even though the real trigger is parent width

These are real bugs, but they often surface first as flakiness. The test is usually doing its job by making unstable layout logic visible.

Final judgment

To test ResizeObserver-driven UI updates well, treat them as browser timing problems, not just DOM assertion problems. Resize the right element, wait for the final rendered state, and add at least one check that can reveal a render-observe-render loop.

If you only remember one rule, make it this: assert after layout settles, not after you think the observer should have run.

FAQ

Can I test ResizeObserver behavior by mocking it in Jest?

Yes, but only for pure logic. Mocking ResizeObserver will not prove browser timing, layout, or repaint behavior.

Should I use waitForTimeout after resizing?

Usually no. Prefer assertion-based waiting, which is tied to the actual DOM state you care about.

Is page.setViewportSize enough for all responsive tests?

No. It helps when the component responds to viewport width, but not when the component observes a parent container or a specific element.

How do I know if I have a ResizeObserver loop?

Watch for repeated state changes or repeated visible layout changes after one resize. A counter or debug marker in test builds can make this easier to detect.

What is the most stable thing to assert?

A user-visible end state, such as text, class, visibility, or item count, after the layout has finished settling.