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.