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”.
Observers Add a Scheduling Layer of Their Own #
A mutation observer does not run when the DOM changes; it runs afterwards, in a microtask, with a batch of records. That single fact explains most of the confusion around observer-driven interfaces.
Three consequences follow. Callbacks are batched, so several mutations produce one invocation with several records rather than several invocations — a test counting callbacks measures batching behaviour rather than mutations. The callback can itself mutate, producing a second round of records and a second invocation, so the DOM a test observes immediately after the first change may be an intermediate state. And the observer runs after the mutation is already visible, which means a test racing the callback can legitimately see markup that has not yet been processed.
The practical implication is to assert on the settled result rather than on any intermediate structure. An interface that lazily decorates elements — adding tooltips, formatting numbers, attaching handlers — passes through a state where the element exists undecorated, and a test asserting on presence alone will sometimes catch it there. Asserting on the decoration, not on the element, spans the whole sequence.
// Assert the processed result, which spans the observer's own scheduling.
// Trade-off: a retrying assertion is marginally slower to fail and cannot be
// caught mid-batch, which a presence check regularly is.
await expect(page.getByTestId('amount')).toHaveText('£1,250.00'); // formatted
When the Test Needs Its Own Observer #
Occasionally a test genuinely needs to know that a mutation happened rather than that a final state was reached — verifying that a list updates incrementally rather than re-rendering wholesale, for instance, or that a virtualised container recycles nodes rather than accumulating them.
An observer installed from the test can answer that, and it comes with two constraints worth knowing before relying on it. It must be installed before the mutation, which for anything happening during page load means an init script rather than a call after navigation. And it must be disconnected when the test finishes, or it keeps observing a document that the next test is using, holding references and occasionally firing callbacks that touch a page that no longer exists.
The subtlety that catches people is subtree. An observer without it sees only direct children of its target, so a change nested two levels down produces nothing and the test concludes the mutation never happened. Enabling it fixes that and increases volume considerably on a busy page, which is why filtering the records — by attribute name, by node type, by matching selector — is usually necessary to keep the assertion meaningful.
Used this way an observer is a measurement instrument rather than a synchronisation device. Waiting for a settled state remains the better default; observing mutations is for the cases where the sequence itself is the behaviour under test.