skip to content

A Vue 3.5 SSR app logs only 'Hydration completed but contains mismatches.' in production, and a theme class is stuck wrong after load; how do you diagnose it?

level: seniorimportance: should knowfreq 38%

answer

  1. production strips the details
  2. reproduce with a development SSR build
  3. a details flag for production builds
  4. which mismatches Vue corrects
  5. class is check-only

basics

~20 s

Production builds print one generic error, so reproduce with a development SSR build or a production build with VUE_PROD_HYDRATION_MISMATCH_DETAILS enabled. The stuck class is likely a separate, silent mismatch: class differences are not corrected, and plain production builds do not even check them.

solid answer

~50 s

The generic error is all a production build prints: Vue strips the per-node warnings and logs `Hydration completed but contains mismatches.` once per page load. To see which node, run the same page through a development SSR build, or build production with the compile-time flag `__VUE_PROD_HYDRATION_MISMATCH_DETAILS__` (3.4+, default `false`) enabled temporarily. Then read the recovery rules. Vue overwrites mismatched text, replaces wrong nodes and removes extra ones, but class, style and attribute mismatches are check-only: the DOM keeps the server value, and a plain production build does not even check them. So the stuck class did not cause the generic error. It stays wrong because later patches diff old vnode against new vnode, not against the DOM. The usual root cause is render-time browser state such as a theme from `localStorage` or `matchMedia`; render it from something the server knows (a cookie) or apply it after mount.

code

vue · 21 lines
vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'

// Buggy: reads browser-only state before rendering, so the
// server (false) and the hydrating client (true) disagree.
// const dark = ref(
//   typeof window !== 'undefined' &&
//     window.matchMedia('(prefers-color-scheme: dark)').matches,
// )

// Fixed: both renders start from the same value; the client
// switches after hydration as an ordinary reactive update.
const dark = ref(false)
onMounted(() => {
  dark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
})
</script>

<template>
  <div class="shell" :class="{ dark }"><slot /></div>
</template>

go deeper

for a junior

Recall that production builds hide hydration details and that a dev SSR build shows which element mismatched.

for a middle

Explain the recovery table: text overwritten, nodes re-mounted, class, style and attributes only checked, and the details flag for production builds.

for a senior

Separate the silent class mismatch from the logged one, explain why vnode-to-vnode patching keeps the stale class, and fix the render-time browser state at its source.

for a principal

Build mismatch detection into CI against development SSR, and decide where user preferences such as theme live so the server can render them correctly.

## Step 1: understand what production tells you Vue 3.5's hydration code has two layers of reporting: - **Detailed warnings**, such as `Hydration text content mismatch on …`, `Hydration children mismatch on …`, `Hydration node mismatch`, and `Hydration class mismatch on …`, each with the server and client values. These are compiled in only for development builds, or for production builds with `__VUE_PROD_HYDRATION_MISMATCH_DETAILS__` enabled. - **One generic error**, `Hydration completed but contains mismatches.`, logged with `console.error` at most once per page load, in every build. So in production the generic error proves that at least one mismatch Vue reports happened somewhere on the page, and nothing more. It does not say where, how many, or of what type. ## Step 2: get the details 1. **Reproduce in development SSR.** Render the same route with the same inputs (cookies, headers, data) through a development build; the warnings name the element and print both values. Most mismatches reproduce this way. 2. **If it only happens in production**, build with the details flag. `__VUE_PROD_HYDRATION_MISMATCH_DETAILS__` is a compile-time flag (available since Vue 3.4, default `false`) that keeps the detailed warnings in the production bundle at the cost of extra code. Turn it on for a diagnostic build or a canary, not permanently. 3. **Compare the raw HTML** from the server (view-source or a direct request) with the live DOM after hydration. Invalid nesting shows up here immediately. ## Step 3: know which mismatches Vue corrects | Mismatch | Detected in plain production? | What Vue does | |---|---|---| | Text content | yes, generic error | overwrites with the client text | | Missing or extra child nodes | yes, generic error | mounts missing nodes, removes extra ones | | Wrong node or element type | yes, generic error | removes the server node, mounts the client vnode | | `class`, `style`, attribute | **no** | check-only: the DOM is not rectified | The warning text for the last row says so directly: the mismatch is check-only and the DOM will not be rectified in production due to performance overhead; you should fix the source. Some attributes that are compiled as declared dynamic props can still be patched during hydration, but on ordinary HTML elements `class` and `style` are not. ## Step 4: connect it to the stuck theme class This is the insight the scenario tests: **the stuck class and the generic error are probably two different problems.** A pure class mismatch is not checked in a plain production build, so it cannot be what logged the error. It is also not corrected, so the element keeps the server's class. Why does it not fix itself on the next update? Vue patches by comparing the **old vnode** with the **new vnode**, not by reading the DOM. After hydration the old vnode holds the client's class value. If the next render produces the same value, there is nothing to patch, and the DOM keeps the stale server class until the bound value actually changes. Typical root causes for a theme class: - the render reads `localStorage`, `matchMedia('(prefers-color-scheme: dark)')` or a `window` property, which the server does not have, so the server renders the default theme and the client renders the stored one; - the server renders a theme from a cookie while the client reads a different store. ## Step 5: fix the source, not the symptom - **Make the server know the value:** store the theme in a cookie the server reads, so both renders agree. - **Or defer it:** render the default during hydration and switch in `onMounted`, accepting a visible flip, or apply the theme to the `<html>` element, outside the Vue-rendered tree, with a tiny inline script before the app loads, and sync the reactive state after mount. - **Do not** add `data-allow-mismatch="class"`: it removes the dev warning and leaves the wrong class exactly as it is. - **Also check the mount container:** if it is empty, Vue warns in development that it is performing a full mount instead of hydrating, which hides mismatches entirely and throws away the SSR benefit. Finally, fix whatever produced the generic error too: it is a separate text, node or children mismatch that the detailed build will now name.

  • Why not ship `__VUE_PROD_HYDRATION_MISMATCH_DETAILS__` enabled permanently?
    It keeps the warning code and message strings in the production bundle, which Vue's docs say to enable only for debugging. A diagnostic build or a canary gives the same information without making every visitor download it.
  • What happens if the element the client app mounts on is empty?
    Vue warns in development that it is attempting to hydrate but the container is empty and performs a full client mount instead. The page still works, but the SSR markup was never there to adopt, which usually means the server output was not injected into the template or the mount selector is wrong.
  • How would you catch hydration mismatches before production?
    Run end-to-end tests against a development SSR build and fail the test on any console warning that reports a hydration mismatch. Because production hides the details and does not even check class, style or attribute mismatches, the dev build is the place where they are cheapest to find.

saying these in an interview costs you the question

  • The generic production error names the element that mismatched
  • Vue corrects class and style mismatches during hydration
  • The next reactive update will fix the wrong class
  • Production builds never log anything about hydration mismatches
  • Adding data-allow-mismatch fixes the stuck class