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