Root cause #
A MutationObserver does not fire synchronously when the DOM changes. The browser batches mutation records and delivers them in a microtask after the current task completes. So when your app inserts a placeholder node and a MutationObserver later replaces it with real content (a common pattern in design-system widgets, lazy-rendered lists, and framework portals), there is a gap between “the trigger element exists” and “the final content exists”. An assertion that keys off the trigger element wins the race intermittently — green on a fast local machine, red on a loaded CI runner where the microtask is delayed behind other work.
This is the same class of failure described in DOM Mutation & Rendering Races and across the Root Causes of JavaScript Test Flakiness: the test asserts on an intermediate state. The fix is never a longer sleep — CI variance always eventually exceeds any fixed delay. The fix is to assert on a condition that is only true once the observer has finished its work, and let the test runner retry that condition until it holds.
Step-by-step fix #
1. Assert on the post-mutation condition with Playwright web-first assertions #
Playwright’s expect(locator) assertions auto-retry until the locator satisfies the condition or the timeout elapses. Point them at the final content, not the trigger.
// Web-first assertion polls the DOM until the observer-inserted text appears.
await page.goto('/widget');
// Trade-off: retrying costs only the real settle time; it never sleeps longer
// than needed, so it is both faster and more stable than a fixed wait.
await expect(page.getByTestId('observed-content')).toHaveText('Loaded item 42');
Because toHaveText re-queries on each poll, it naturally waits out the microtask gap without you knowing the exact delay.
2. Wait for a structural predicate with waitForFunction #
When “done” is not a single visible element but a structural fact (e.g. the placeholder is gone and N rows exist), express it directly in the browser.
// Runs in the browser; resolves only once the observer has settled the list.
await page.waitForFunction(() => {
const list = document.querySelector('[data-observed-list]');
// Trade-off: a precise predicate avoids masking — it fails fast if the
// observer never completes, instead of hiding the bug behind a long timeout.
return !!list && !list.querySelector('.placeholder') && list.children.length >= 3;
});
await expect(page.getByTestId('observed-content')).toBeVisible();
3. Lean on Cypress retry-ability with cy.get().should() #
Cypress retries the entire cy.get(...).should(...) chain until the assertion passes, which absorbs the observer delay the same way.
// .should() re-queries until the assertion holds or the command times out.
cy.visit('/widget');
// Trade-off: chaining the assertion onto the query is essential — a bare
// cy.get() then a separate non-Cypress assertion would not retry and stays flaky.
cy.get('[data-observed-list]')
.should('not.contain', 'Loading')
.find('li')
.should('have.length.at.least', 3);
4. Expose a completion signal from the observer for the hardest cases #
If the observer mutates an attribute or content that the test cannot easily distinguish, have the app flip a single deterministic flag when the observer’s work is done, and assert on that.
// In app code: signal completion so tests have one stable thing to wait on.
const observer = new MutationObserver(() => {
if (list.children.length >= expected) {
list.setAttribute('data-observer-done', 'true'); // single source of truth
}
});
// In the test (Cypress): assert on the flag — no guessing about internal timing.
// Trade-off: a tiny app-side hook, but it makes the wait condition unambiguous
// and removes reliance on counting child nodes from the test side.
cy.get('[data-observed-list][data-observer-done="true"]').should('exist');
The same applies to the auto-waiting behavior covered in Fixing Playwright Auto-Waiting Timeouts: give the engine a concrete, retryable condition and it does the waiting for you.
Pitfalls #
- Asserting on the trigger element instead of the result — passes before the observer runs. Mitigation: target the final content or a completion flag.
cy.wait(1000)/page.waitForTimeout(1000)— slow and still racy under CI load. Mitigation: replace with retrying assertions orwaitForFunction.- Breaking the Cypress retry chain — assigning
cy.get()to a variable then asserting separately disables retry. Mitigation: keep.should()chained to the query. - Observer batching across animation frames —
subtree/childListchanges may span several frames. Mitigation: assert on the terminal count/flag, not the first record. - Detached-node reads — querying a node the observer replaced returns stale data. Mitigation: re-query inside the retrying assertion each poll.
Reliability targets #
| Target | Goal |
|---|---|
Fixed waitForTimeout/cy.wait(ms) calls in suite |
0 |
| Observer-driven assertions using retry/web-first matchers | 100% |
| Flake rate on observer-dependent specs | < 1% over 200 CI runs |
| Median extra wait beyond real settle time | < 50 ms |
| CI pass-rate goal for these specs | > 99.5% |
Frequently Asked Questions #
Q: Why does adding a 500ms wait fix it locally but not on CI? A: A fixed wait only works while the machine is faster than the delay you guessed. CI runners are slower and more variable, so the MutationObserver microtask sometimes lands after your sleep. Retrying assertions wait exactly as long as needed on every machine, eliminating the guess.
Q: Do I need MutationObserver knowledge to test components that use it internally? A: Not the internals — but you must assert on the result of its work, not the trigger. Target the final text, count, or a completion attribute and let Playwright or Cypress retry until that condition is true.
Q: Is waitForFunction better than a web-first assertion?
A: Use a web-first assertion (toHaveText, toBeVisible) when the condition is about one element. Reach for waitForFunction when “done” is a structural fact spanning several nodes, such as “placeholder gone and at least three rows present”.