Hydration errors

What hydration is

renderToString on the server, createSSRApp().mount() walking the DOM on the client, how Nuxt ships state so both renders agree, and what Vue does when they do not.

Interviewers open with "what is hydration?" not to hear a definition but to find out whether you know what the client actually does with the server HTML, and therefore why the fixes on the following pages work. A mid-level answer says "Vue attaches event listeners". A senior answer explains the parallel walk, where the inputs come from, and what recovery costs.

Know

  • Server: Nitro's render handler builds the app with createSSRApp and calls Vue's renderToString(app, ssrContext) from vue/server-renderer. Nuxt wraps the result with the head tags unhead collected and with the payload: useState and useAsyncData/useFetch results serialised (via devalue) into a <script type="application/json" id="__NUXT_DATA__"> block.
  • Client: the entry calls createSSRApp(App).mount('#__nuxt'). Because the app was created with createSSRApp, mount hydrates: Vue runs the first render, and for every vnode it produces it steps to the next existing DOM node, claims it (vnode.el), attaches listeners and moves on. It creates nothing. Hydration succeeds only if the client's first render yields the same tree the server rendered.
  • Same inputs on both sides is Nuxt's job. The payload is revived before the app renders, so useState refs and useAsyncData results already hold the server's values on the client; useCookie reads the request's Cookie header on the server and document.cookie in the browser, so both see the same value; unhead renders head tags on the server and hydrates them on the client. Anything outside these channels (Date.now(), window, a raw $fetch) is an input only one side has.
  • Development (Vue 3.4+): the console shows Hydration node mismatch, Hydration text mismatch, Hydration children mismatch, Hydration attribute/class/style mismatch, each with "rendered on server" against "expected on client" plus a component trace, and finally Hydration completed but contains mismatches.
  • Production: silent recovery. Text content is overwritten; a node of the wrong type is discarded and the vnode mounted fresh; surplus server nodes are removed, missing ones mounted. Costs: layout shift, a flash of the server version, lost server-rendered content, listeners bound to nodes that get replaced, and a partial re-mount on the main thread while the user waits for interactivity.
  • __VUE_PROD_HYDRATION_MISMATCH_DETAILS__ (Vue 3.4, compile-time flag, default false) makes production builds log the same details as development, at some bundle cost. Vue's docs set it through Vite define; Nuxt's debug option does the same thing for you: debug: { hydration: true } (or debug: true, which expands to every debug flag) sets the define in the Vite builder.
  • data-allow-mismatch (Vue 3.5) declares an expected mismatch on one element. Values: text, children (direct children only), class, style, attribute; no value allows every kind. It suppresses the warning; the recovery still happens.
  • useId() (Vue 3.5, import from vue) returns an id that is unique per app instance and identical on server and client, the correct source for generated id / aria-* / for attributes. Prefix apps sharing one page with app.config.idPrefix; never call it inside computed. Nuxt 4 no longer documents a useId of its own; use Vue's.

How it works

Stripped of Nuxt's plumbing, the two entries look like this:

simplified: what the Nitro renderer and the client entry do
// server (inside Nitro's render handler)
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'

const app = createSSRApp(App)
const html = await renderToString(app, ssrContext) // components run once in Node
// ...Nuxt adds unhead tags + the devalue payload and sends the document

// client (app entry)
const app = createSSRApp(App)
app.mount('#__nuxt') // components run again; Vue walks the existing DOM instead of creating it

Turning production details on for one environment only, so a staging build tells you what production would not:

nuxt.config.ts
export default defineNuxtConfig({
  $env: {
    staging: {
      // sets __VUE_PROD_HYDRATION_MISMATCH_DETAILS__ = true in the Vite build
      debug: { hydration: true },
    },
  },
})

Build it with nuxt build --envName staging. The equivalent without the Nuxt option is vite: { define: { __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'true' } }.

A component that uses both Vue 3.5 tools, one to prevent a mismatch and one to declare an acceptable one:

app/components/PublishedAt.vue
<script setup lang="ts">
import { useId } from 'vue'

defineProps<{ iso: string }>()
const labelId = useId() // identical on server and client
</script>

<template>
  <p>
    <span :id="labelId">Published</span>
    <!-- Node and the browser format dates with different locale/timezone; the flash is acceptable here -->
    <time :datetime="iso" :aria-labelledby="labelId" data-allow-mismatch="text">
      {{ new Date(iso).toLocaleString() }}
    </time>
  </p>
</template>
Gotcha· Attributes are warned about, not repaired

During hydration Vue patches text and replaces wrong nodes, but for a plain attribute, class or style mismatch it only logs. The element keeps the server's value until that binding is next updated by a reactive change. A theme class computed from localStorage therefore does not just flash, it stays wrong while your state says otherwise, and no error ever tells the user.

Gotcha· data-allow-mismatch is a declaration, not a fix

It silences the warning for one element and keeps the recovery (the text is still overwritten, the flash still happens). Use it where a mismatch is inherent, such as a timestamp formatted in the user's locale. Sprinkling it over components to get a clean console is how a team loses the signal for real bugs.

Gotcha· A secure cookie over plain HTTP hydrates wrong

useCookie(name, { secure: true }) on a non-HTTPS origin: the browser refuses to send the cookie back, the server renders the default while the client reads the value it set locally, and the mismatch looks like a state bug. The Nuxt docs call this out; test cookie-driven UI over HTTPS or without secure in local dev.

Verify before the interview:

debug accepts true or a granular object; the schema resolves true to { templates, modules, watchers, hooks, nitro, router, hydration, perf } and the Vite define block derives __VUE_PROD_HYDRATION_MISMATCH_DETAILS__ from debug.hydration. Re-check the option name on the config reference the week before; the study guide also claims Nuxt DevTools surfaces hydration details, which is not documented on the DevTools features page.

docs ↗

Exercise

Exercise
  • In a fresh nuxt dev playground, render {{ Date.now() }} in app/pages/index.vue. Read the full warning: which mismatch type is it, what did the server render, what did the client expect, which component does the trace name?
  • Run nuxt build && node .output/server/index.mjs. Confirm the console is silent and the value still flips after load (that is the recovery).
  • Add debug: { hydration: true } to nuxt.config.ts, rebuild, and confirm the production bundle now logs the details. Compare the client bundle size with and without the flag.
  • Replace Date.now() with useState('now', () => Date.now()) and watch the warning disappear; open view-source and find the value inside #__NUXT_DATA__.
  • Put the timestamp back, format it with toLocaleString(), add data-allow-mismatch="text", and notice the console is clean while the text still flashes.

Be able to say

Be able to say· What is a hydration mismatch, and what does Vue do about it in production?

"The server renders the component tree to HTML with renderToString; the client calls createSSRApp().mount(), which runs the same render but walks the existing DOM and claims nodes instead of creating them. A mismatch means the client's first render produced a different tree, because some input existed on only one side: a timestamp, a browser API, data fetched outside the payload, or HTML the browser corrected. In development Vue names the element and prints server versus client. In production it recovers silently: text is patched, wrong nodes are re-created, attribute mismatches are not even repaired. The user pays in layout shift, a content flash and a partial re-mount on the main thread. Nuxt's answer is to feed both renders the same inputs through useState, useAsyncData and useCookie, and Vue 3.5 adds useId for stable ids and data-allow-mismatch for the rare mismatch you intend."