Hydration errors

Diagnosing, step by step

A seven-step workflow from reading the warning to a production build that talks, and the E2E test that turns the manual check into a CI gate.

"A consumer reports hydration warnings on their dashboard, you cannot reproduce it. What do you do?" is the scenario question for this section. The interviewer wants a method, not a guess: something that narrows the search space step by step and ends with a regression test rather than a fix and a hope. This is that method.

Know

  1. Read the warning. Since Vue 3.4 the development warning names the mismatch type (node, text, children, attribute, class, style), prints what was rendered on the server against what the client expected, and attaches a component trace. Click through to the component; half the catalogue is identifiable from this message alone.
  2. Diff server against client. Capture the server output with curl -s http://localhost:3000/page > server.html (or view-source:). In the browser, after load, run copy(document.documentElement.outerHTML) and paste into client.html; format both with npx prettier --parser html and diff. The post-hydration DOM is the repaired version, which is the point: the diff shows exactly what Vue changed. A tab with JavaScript disabled shows the raw server DOM if you need it.
  3. Production-only? Then the warning is compiled out. Build a staging variant with debug: { hydration: true } (or vite: { define: { __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'true' } }) and reproduce there. Do not ship it: the flag adds bundle size and logs to every user.
  4. Only some users? Suspect environment: browser extensions (reproduce in a clean profile or incognito), locale and timezone (run the server as the user would experience it: TZ=Europe/Prague LANG=cs_CZ.UTF-8 node .output/server/index.mjs), auth state (logged in against logged out), and cached HTML (check age and cache-status response headers, and whether it started right after a deploy).
  5. Bisect by wrapping halves of the page in <ClientOnly> until the warning disappears, then look at what you wrapped. Remove the wrappers when you have the culprit; they are a probe, not a fix.
  6. Check the payload. Nuxt DevTools has a Payload tab; view-source has the #__NUXT_DATA__ script; the console has useNuxtApp().payload.data and .state. Ask: is the data the client renders actually there? Missing means it was fetched outside the payload (cause 10) or with server: false; present but different means a transform/pick difference or a key collision (cause 9).
  7. Automate. Write an E2E test with createPage that collects console messages and fails on anything matching /Hydration/. Run it against every fixture. This is the test a layer or module ships with its playground.

How it works

The test, with @nuxt/test-utils driving Playwright from Vitest:

test/e2e/hydration.test.ts
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { createPage, setup, url } from '@nuxt/test-utils/e2e'

const routes = ['/', '/dashboard', '/settings/profile']

describe('hydration', async () => {
  await setup({
    rootDir: fileURLToPath(new URL('../../playground', import.meta.url)),
    browser: true,
    // production build (what users get), but with Vue's mismatch details compiled in
    nuxtConfig: { debug: { hydration: true } },
  })

  it.each(routes)('%s hydrates without warnings', async (route) => {
    // no path here: the console listener must exist before navigation starts
    const page = await createPage()
    const problems: string[] = []
    page.on('console', (msg) => {
      if (/Hydration/.test(msg.text())) problems.push(msg.text())
    })
    page.on('pageerror', (error) => problems.push(error.message))

    // waits for window.useNuxtApp().isHydrating === false, not just for `load`
    await page.goto(url(route), { waitUntil: 'hydration' })

    expect(problems).toEqual([])
    await page.close()
  })
})

Why each line is there: browser: true launches Playwright (install playwright-core and a browser binary with npx playwright install chromium); nuxtConfig overrides the fixture's config for this run, so the production bundle logs mismatches without the flag ever reaching a real deploy; createPage() without a path returns a page that has not navigated yet, so no early warning is lost; waitUntil: 'hydration' is test-utils' extension of Playwright's goto, which polls window.useNuxtApp?.().isHydrating === false, because load fires before Vue has finished walking the DOM.

The same check with the Playwright test runner, if the team prefers it over Vitest:

test/e2e/hydration.spec.ts
import { expect, test } from '@nuxt/test-utils/playwright'

test('home hydrates cleanly', async ({ page, goto }) => {
  const problems: string[] = []
  page.on('console', msg => /Hydration/.test(msg.text()) && problems.push(msg.text()))
  await goto('/', { waitUntil: 'hydration' })
  expect(problems).toEqual([])
})

Run the Vitest version in CI with a small matrix: the default environment plus one job with TZ=Europe/Prague and a non-English LANG, which is how cause 2 is caught before a user in Prague reports it.

Gotcha· A green test against a plain production build proves nothing

setup() builds and serves a production bundle by default. Without debug.hydration Vue never logs a mismatch there, so the console stays clean and the test passes with a broken page. Either run the fixture in dev mode (dev: true) or, better, keep the production build and switch the details on through nuxtConfig.

Gotcha· createPage('/route') navigates before you can listen

Passing a path to createPage performs goto immediately, and hydration warnings are emitted during that navigation. Attach page.on('console') first, then navigate yourself.

Gotcha· Bisecting with ClientOnly is a probe

Every <ClientOnly> you leave in place deletes server rendering for that subtree, and its default slot's CSS is no longer inlined in the initial HTML. Find the cause with it, then take it out and apply the fix from the catalogue.

Verify before the interview:

setup() options used here (rootDir, browser, nuxtConfig, dev) and the waitUntil: 'hydration' extension are current in @nuxt/test-utils; the goto fixture of the Playwright integration accepts the same option. Re-check the option names against the testing page shortly before the interview.

docs ↗

Exercise

Exercise
  • Add the Vitest E2E test above to a playground and wire it into CI. Confirm it fails when you add {{ Date.now() }} to a page and passes when you remove it.
  • Create a mismatch that only reproduces in production (for example a value read from localStorage that dev-mode HMR happens to mask), confirm the plain production build is silent, then find it with debug: { hydration: true }.
  • Do the server/client diff by hand once: curl the page, copy outerHTML, prettier both, diff. Identify the changed nodes and match them to the warning.
  • Open the Chrome Performance panel, record a load with a layout-affecting mismatch, read the CLS entries, fix the mismatch, record again.

Be able to say

Be able to say· How do you debug a hydration mismatch that only happens in production?

"First I accept that production is silent by design: Vue compiles the warnings out, so I build a staging variant with debug: { hydration: true }, which sets Vue's __VUE_PROD_HYDRATION_MISMATCH_DETAILS__ flag, and reproduce there. Then I diff the server HTML from curl against the browser DOM after load to see exactly what Vue repaired. If only some users hit it, I reproduce with their environment: timezone and locale on the server process, logged in versus out, a clean browser profile to rule out extensions, and the cache headers to rule out stale or personalised HTML. If I still cannot see it, I bisect with <ClientOnly> and check the payload in DevTools to see whether the data the client renders was ever there. The fix ends with an E2E test that collects console messages after waitUntil: 'hydration' and fails on the word Hydration, so it cannot come back."