Article · Root Causes of JavaScript Test Flakiness

Waiting for React Hydration Before Assertions

Server-rendered React markup is visible long before the client bundle hydrates it, so a test that clicks or asserts on the SSR HTML can interact with an element whose event handlers do not yet exist — the click is swallowed and the test fails intermittently. This guide, part of DOM Mutation & Rendering Races, shows how to detect a real hydration signal and gate Playwright and Cypress assertions on it instead of on a fixed delay.

13 sections URL: /root-causes-of-javascript-test-flakiness/dom-mutation-rendering-races/waiting-for-react-hydration-before-assertions/
SSR markup versus hydration handler attachment timeline SSR HTML is visible early; React hydration attaches handlers later, so a click before hydration is dropped while a click after a hydration signal succeeds. Page load timeline SSR HTML painted visible bundle downloads + parses click here = dropped hydration attaches handlers interactive click here = handled Gate on a hydration signal data-hydrated="true" network idle element actually responds Assert interactivity, not mere visibility.
SSR markup is visible before hydration; gate interaction on a hydration signal, not on the paint.

Root cause #

With SSR and frameworks like Next.js, the server streams fully-formed HTML so the page paints almost immediately. React then downloads the client bundle and hydrates: it walks the existing DOM and attaches event listeners and state. Between paint and hydration the page looks complete but is inert — buttons render, yet onClick is not wired. A Playwright toBeVisible() or a Cypress cy.get(...).should('be.visible') is satisfied by the SSR markup and lets the test proceed, so a subsequent click fires into a node with no handler. On a fast machine hydration usually wins; under CI load it loses, producing the classic intermittent failure.

This is a timing race in the same family as the rest of DOM Mutation & Rendering Races and the wider Root Causes of JavaScript Test Flakiness: the element exists in the DOM before it is functional. Visibility is the wrong gate. The correct gate is interactivity — a signal that hydration has attached handlers.

Visible but inert until hydration SSR paints the button, then the bundle hydrates and attaches the handler; only then is a click handled. SSR paints button bundle hydrates handler attached → click works a click in the amber gap is silently dropped
The button is visible during hydration but inert — gate on interactivity, not paint.

Step-by-step fix #

1. Emit an explicit hydration flag from the app #

The most robust signal is one the app controls: set an attribute once the root component has mounted on the client.

// app/HydrationGate.tsx — flips a flag after the first client effect runs.
'use client';
import { useEffect } from 'react';
export function HydrationGate() {
  // Trade-off: a one-line app hook, but it gives tests a single deterministic
  // signal instead of inferring readiness from timing or network heuristics.
  useEffect(() => { document.documentElement.setAttribute('data-hydrated', 'true'); }, []);
  return null;
}

2. Gate Playwright on the flag, then interact #

Wait for the attribute with a web-first assertion so Playwright retries until hydration completes, then act.

await page.goto('/dashboard');
// Trade-off: waiting on the flag adds only the real hydration time and never
// masks a broken bundle — if hydration never runs, this fails fast and clearly.
await expect(page.locator('html')).toHaveAttribute('data-hydrated', 'true');
await page.getByRole('button', { name: 'Save' }).click(); // handler is now attached

3. When you cannot add a flag, assert interactivity directly #

Without an app change, prove the element responds rather than merely exists. Wait for a side effect that only a hydrated handler can produce.

// Click, then assert on an effect only a wired handler can cause.
await page.getByRole('button', { name: 'Open menu' }).click();
// Trade-off: this tolerates a pre-hydration no-op click because the assertion
// retries; but prefer the explicit flag — relying on click retries is noisier.
await expect(page.getByRole('menu')).toBeVisible();

page.waitForLoadState('networkidle') is a weaker heuristic — useful as a fallback for apps that finish their hydration-triggering requests at idle, but it can fire early on streaming responses, so pair it with an interactivity assertion.

4. Gate Cypress with retry-able readiness checks #

Cypress retries cy.get().should() chains, so assert on the hydration flag (or a wired control) before clicking.

cy.visit('/dashboard');
// .should() re-queries until the flag is present — absorbs hydration delay.
// Trade-off: keep the assertion chained to the query so retry-ability holds;
// a separate variable read would not retry and would re-introduce the race.
cy.get('html[data-hydrated="true"]').should('exist');
cy.get('[data-testid="save"]').click();
cy.contains('Saved').should('be.visible'); // confirms the handler actually fired

The retry mechanics here are the same as in Stabilizing MutationObserver Timing in E2E Tests and the auto-waiting tuning in Fixing Playwright Auto-Waiting Timeouts: give the runner a concrete, retryable readiness condition.

Hydration gate signals ranked by reliability An explicit data-hydrated flag is most reliable, then an interactivity assertion, then networkidle as a weak fallback. data-hydrated flagmost reliable interactivity assertgood networkidle onlyweak / early
Prefer an explicit flag; treat `networkidle` as a fallback that can fire before hydration.

Pitfalls #

  • Treating toBeVisible() as “ready” — SSR markup is visible pre-hydration. Mitigation: gate on a hydration flag or an interactivity side effect.
  • Fixed waitForTimeout/cy.wait(ms) — guesses the hydration duration and fails under CI variance. Mitigation: wait on a deterministic signal.
  • networkidle alone on streaming/Suspense pages — can resolve before hydration completes. Mitigation: combine with an interactivity assertion.
  • First click silently dropped — pre-hydration clicks are no-ops with no error. Mitigation: assert on the click’s effect and let it retry, or gate first.
  • Hydration mismatch warnings ignored — a mismatch can re-render and detach the node you targeted. Mitigation: fix mismatches and re-query inside the retry.
Gate then interact Wait for the hydration flag, then perform the interaction, then assert the handler effect. wait data-hydrated click control assert handler effect
Gate on the flag, act, then confirm the handler actually fired.

Reliability targets #

Target Goal
Interactions gated on a hydration signal 100% of SSR specs
Fixed sleeps before first interaction 0
Flake rate on hydration-sensitive specs < 1% over 200 CI runs
Median wait beyond real hydration time < 75 ms
CI pass-rate goal for SSR/Next.js suites > 99.5%
Hydration-gating scorecard Targets for gated interactions, fixed sleeps, flake rate, and extra wait beyond hydration. 100%gated SSR specs 0fixed sleeps < 1%flake rate < 75msextra wait
Every SSR interaction gated on a hydration signal, with near-zero wasted wait.

Frequently Asked Questions #

Q: The button is visible, so why does the click do nothing? A: SSR paints the button before React hydrates and attaches its onClick. Visibility is satisfied by inert markup. Gate the interaction on a hydration flag (data-hydrated="true") or assert on a side effect the handler produces, so the test only clicks once handlers exist.

Q: Is networkidle enough to know hydration finished? A: Not reliably. Streaming SSR and Suspense can reach network idle before client hydration runs, and some apps keep background requests open. Use it only as a fallback and confirm interactivity with an assertion on the handler’s effect.

Q: I cannot modify the app to add a hydration attribute — what then? A: Assert interactivity directly: perform the action and wait (with a retrying assertion) for an effect only a wired handler can cause, such as a menu opening or a toast appearing. It is slightly noisier than an explicit flag but removes the visibility-only race.

The Most Misdiagnosed Race in the Catalogue #

Hydration failures pass every condition a test normally checks, which is what makes them so consistently mistaken for something else. The markup arrives complete from the server, so the element exists, is visible, contains the right text and has a stable bounding box — and it does nothing when clicked, because the JavaScript that attaches behaviour has not run.

The failure therefore surfaces somewhere other than the click: a navigation that never happens, a dialog that never opens, a state that never changes. Engineers investigate the assertion that failed rather than the interaction that silently went nowhere, which is usually several steps earlier.

Two signatures identify it quickly. The failure disappears when a delay is inserted before the interaction — not because timing is the fix, but because hydration completes during the wait. And it correlates with page weight rather than with the feature under test: the heaviest routes hydrate last and fail most.

The reliable signals are ones the application publishes deliberately. A flag set after hydration completes, a data attribute on the root, or an element that only renders on the client all give a test something to wait for that means “interactive” rather than “present”. Where the application cannot be changed, asserting on a consequence of hydration — a control responding, a value only the client computes — is closer than asserting on markup that was already there.

// Wait for interactivity, not for presence.
// Trade-off: this needs a signal the app publishes; without one, every wait is
// inferring from something correlated rather than observing readiness.
await page.waitForFunction(() => window.__APP_HYDRATED__ === true);
await page.getByRole('button', { name: 'Add to basket' }).click();

Server and Client Output That Legitimately Differ #

The second family of hydration problems is not about timing at all: it is about the server and the client rendering different content on purpose, and a test observing the moment the two swap.

Timestamps are the usual culprit. A date rendered as an absolute value on the server and a relative one on the client changes at hydration, so an assertion that catches the transition sees a value that was correct a fraction of a second earlier. Anything derived from the viewport, from browser capabilities or from stored preferences behaves the same way, since none of that information exists during server rendering.

Freezing the clock removes the timestamp case and does nothing for the others, which need the test to wait for the client-rendered state explicitly rather than asserting on whatever is currently on screen. The general rule is to assert after hydration for anything the client computes, and to treat a mismatch warning in the console as a finding rather than noise — a genuine hydration mismatch means users briefly see different content too, and it frequently produces a flash of incorrect state that nobody has noticed because it is too fast to catch by hand.

Ask the Application, Not the DOM #

A readiness signal published by the application beats any inference drawn from markup that was already present before hydration began.