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.

10 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.

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.

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.

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%

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.