# Use hydration-proof with Playwright

> Find every Playwright hydration error your e2e suite misses: hydration-proof drives Playwright, waits for React to hydrate and reuses your browsers and login.

Source: https://hydration.jscrate.dev/docs/playwright
Last updated: 2026-09-18

A Playwright hydration error test needs more than `page.goto()` and a console
listener. hydration-proof is built on Playwright: it waits for React to finish
hydrating, compares the server HTML with the hydrated DOM, and reports the
differences React never logs. Run it next to your e2e suite, and reuse that
suite's browsers, login and server.

## Built on Playwright

hydration-proof drives Chromium, Firefox and WebKit through `playwright-core`
1.63 or newer, its only runtime dependency. `playwright-core` downloads nothing
when you install it, and at run time hydration-proof prefers your project's own
copy when it is new enough:

- The browsers you already have in `~/.cache/ms-playwright` are used as they
  are. Nothing is downloaded twice.
- `npx hydration-proof install` is only needed when the project has no
  Playwright browsers at all.

## Why does a Playwright hydration error go unnoticed?

A common way to catch hydration errors in Playwright tests is a console
listener:

```ts title="e2e/home.spec.ts"
import { expect, test } from "@playwright/test";

test("home page has no hydration errors", async ({ page }) => {
  const errors: string[] = [];
  page.on("console", (message) => {
    if (message.type() === "error") errors.push(message.text());
  });

  await page.goto("/");
  expect(errors).toEqual([]);
});
```

It misses most problems and explains none of them. Playwright hydration issues
slip through for four reasons:

1. **Production builds say almost nothing.** At best the console shows a
   minified error code such as `Minified React error #418`, with no element
   and no values, and nothing at all for attribute mismatches.
2. **React 19 keeps wrong attributes silently.** In production, a mismatched
   `className`, `style` or `data-*` attribute is never reported and never
   fixed.
3. **React recovers.** When the text or structure differs, React 19 throws the
   server HTML away and renders the page again on the client. The page works,
   so assertions about its content still pass, while users get a slower page
   and lose state.
4. **The server HTML looks ready too early.** Buttons and fields are visible
   before React attaches its handlers, so a test that clicks as soon as an
   element appears can act before hydration, or wait for the wrong thing.

hydration-proof checks for all of this directly. It compares each stage of the
page, from the server HTML to the hydrated DOM, and audits every attribute
against the props React renders on the client. See
[how it works](https://hydration.jscrate.dev/docs/how-it-works).

## How to make Playwright wait for hydration

Waiting for hydration is the hard part. The page's load event does not mean
React has hydrated, and a visible element proves nothing, because the server
HTML rendered it.

hydration-proof connects to React through `__REACT_DEVTOOLS_GLOBAL_HOOK__`,
the hook React looks for when it loads, in development and production builds.
It provides the hook, or wraps an existing one so React DevTools and Fast
Refresh keep working, and React then reports every renderer and every commit
to it. So hydration-proof knows when the root and each Suspense boundary have
hydrated.

After hydration, it waits until the page is quiet. It never waits for
"network idle". The `ready` option sets the rules:

```ts title="hydration-proof.config.ts"
import { defineConfig } from "hydration-proof";

export default defineConfig({
  ready: {
    // Wait for the same thing the e2e suite waits for.
    selector: "[data-app-ready]",
    // No DOM changes or React commits for this long.
    quietMs: 400,
    // Hydration must finish within this time once React has loaded.
    hydrationTimeout: 15_000,
    // The most time one page may take.
    timeout: 30_000,
  },
});
```

`ready.function` is a page function, written as a string, that must return a
truthy value before the final snapshot. A route can have its own `ready`
settings in `routes.paths`.

A page whose hydration does not finish in time is reported as
[HP9001](https://hydration.jscrate.dev/docs/issues/hp9001). A page that never becomes quiet (animations,
polling, live data) is reported as [HP9009](https://hydration.jscrate.dev/docs/issues/hp9009): set
`ready.selector` or `ready.function`, or lower `ready.quietMs`.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `quietMs` | `number` | 400 | Quiet time (ms) without DOM changes or React commits before the page counts as settled. |
| `timeout` | `number` | 30000 | Maximum time (ms) per page. |
| `hydrationTimeout` | `number` | 15000 | Maximum time (ms) for hydration to finish once React is loaded. |
| `selector` | `string` | — | A selector that must exist before the final snapshot. |
| `function` | `string` | — | Page function source that must return truthy before the final snapshot. |

## Use it with an existing Playwright setup

Three things from your Playwright setup are worth reusing:

1. **The browsers.** Nothing to do: see [above](#built-on-playwright).
2. **The login.** If your Playwright global setup signs in and saves
   `storageState` (for example to `playwright/.auth/user.json`), point a
   scenario's `storageState` at the same file. The login then runs once, in
   whichever suite runs first.
3. **The server.** If Playwright's `webServer` already starts the app, leave
   `server` out of the hydration-proof config and pass the URL.

```ts title="hydration-proof.config.ts"
import { defineConfig } from "hydration-proof";

export default defineConfig({
  // The app is already running: CI starts it, or Playwright's webServer does.
  server: {
    url: process.env.BASE_URL ?? "http://localhost:3000",
    reuseExisting: true,
  },

  routes: { discover: true },

  scenarios: [
    {
      name: "signed-in",
      // The file Playwright's global setup already wrote.
      storageState: "playwright/.auth/user.json",
    },
  ],

  ready: {
    selector: "[data-app-ready]",
    quietMs: 200,
    timeout: 15_000,
  },
});
```

Or skip the config and pass the URL on the command line:

```bash
npx hydration-proof test --url http://localhost:3000
```

For signed-in pages with a `login` function instead of a saved state, see
[scenarios and sign-in](https://hydration.jscrate.dev/docs/scenarios).

## Run it next to your tests, not inside them

> **Do not run hydration-proof inside a Playwright test**
>
> hydration-proof launches and drives its own browser contexts, takes six DOM
> snapshots per page and needs each page loaded without interference. Nested in
> a Playwright worker, it fights your tests for the same browser and slows both
> down.

Run it as its own command, next to the e2e suite. When a script needs to do
something around the run, such as start a server, seed a database or publish
the report, call `run()` from Node:

```ts title="run-from-node.ts"
import { run } from "hydration-proof";

const { exitCode, report } = await run({
  overrides: { url: "http://localhost:3000", reporters: ["json"] },
  write: (text) => process.stdout.write(text),
});

// The report is the same object the JSON reporter writes.
for (const issue of report.issues) {
  if (issue.severity !== "error") continue;
  console.log(
    `${issue.route.pattern} ${issue.code} ${issue.source?.file ?? "(no source)"}`
  );
}

process.exit(exitCode);
```

The [Node API](https://hydration.jscrate.dev/docs/node-api) covers `run()` and its options.

## Related

- [How hydration-proof works](https://hydration.jscrate.dev/docs/how-it-works): the stages it compares
- [The Node API](https://hydration.jscrate.dev/docs/node-api) for scripts around a run
- [Scenarios and sign-in](https://hydration.jscrate.dev/docs/scenarios)
- [Lost clicks and input before hydration](https://hydration.jscrate.dev/docs/interactions)
- [Minified React error #418](https://hydration.jscrate.dev/docs/errors/minified-react-error-418)
