Home › Frontend › Vue Hydration Mismatch: SSR HTML Differs From Client
Intermediate 5 min · September 23, 2026

Vue Hydration Mismatch: SSR HTML Differs From Client

Random values and browser-only APIs split server HTML from client DOM.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Lessons pulled from things that broke in production.

Follow
✓ Production
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 10 min
  • ✓How SSR renders HTML on the server before client JavaScript loads
  • ✓Vue component lifecycle, especially setup versus onMounted
  • ✓Nuxt basics or general SSR deployment concepts
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • Hydration fails when server-rendered HTML differs from the client's first render output
  • Random IDs, Date.now(), and new Date() produce different values on server versus client
  • Browser-only APIs (window, localStorage, matchMedia) don't exist during SSR and must be deferred
  • Wrap nondeterministic UI in (Nuxt) or mount-gated v-if so SSR skips it
  • Reproduce by diffing page source against first-paint DOM with JavaScript disabled
✦ Definition~90s read
What is Vue Hydration Node Mismatch in SSR / Nuxt?

Hydration is Vue's client-side takeover of server-rendered HTML. In SSR (or Nuxt universal mode), the server renders components to an HTML string for fast first paint and SEO. The browser then loads the JavaScript bundle, renders the same tree client-side, and walks the existing DOM adopting nodes — attaching listeners and reactivity without recreating elements.

★
Imagine two chefs asked to plate the same dish from memory — one uses salt, the other sugar, and the waiter can't serve either plate.

This adoption requires the client's first virtual DOM to match the server's HTML exactly: tags, text, and attributes in identical order.

A hydration mismatch means the two renders disagreed. The classic divergences: random values (Math.random keys, generated IDs, experiment variants) rolled independently on each side; timestamps (new Date(), Date.now()) differing by definition; locale or timezone formatting splitting text; browser-only APIs (window, localStorage, matchMedia) absent on the server; and auth state where the server renders anonymous while the client hydrates authenticated.

On mismatch Vue warns and client-renders the subtree, causing content swaps, state resets, and handlers that never attach — the dead-until-reload buttons users report.

The discipline is deterministic SSR: identical inputs must produce identical output on both sides. Randomness resolves before render (cookies, request seeds); browser work defers to onMounted and mounted-gated markup; inherently client-only leaves hide behind <ClientOnly> with dimensioned fallbacks; SSR data fetching forwards request cookies so both sides render as the same user.

Verification closes the loop: no-JS source diffs, warning-free E2E across state matrices, and per-release production alerting.

Plain-English First

Imagine two chefs asked to plate the same dish from memory — one uses salt, the other sugar, and the waiter can't serve either plate. Hydration is Vue comparing server HTML against the client's fresh render: both must match exactly for takeover. Random values, timestamps, and browser-only data make them disagree. The fix: a deterministic recipe plus client-only garnish.

You enable SSR or deploy to Nuxt, and the console fills with Hydration node mismatch warnings. The page flashes wrong content, interactive elements lose state on load, or event handlers silently never attach. Everything works in dev SPA mode — the breakage appears only where server HTML meets client takeover.

The cause is divergence: the server rendered one HTML string, and the client's first render produced a different virtual DOM. Random values (Math.random keys, generated IDs), timestamps (new Date(), Date.now()), locale-dependent formatting, and browser-only APIs (window width, localStorage theme, matchMedia) all differ between the Node server and the user's browser. Even a logged-in-state difference — server rendering anonymous while the client hydrates authenticated — splits the trees.

This article teaches deterministic SSR: identifying every nondeterministic render source, deferring browser-only work to mounted hooks and client-only wrappers, and verifying with no-JS and source-vs-DOM diffs. You'll learn the <ClientOnly> pattern, stable-ID strategies, and how to keep personalization without breaking hydration.

What Hydration Compares and Why Exact Match Matters

SSR sends pre-rendered HTML so users see content before JavaScript loads. Hydration is the client-side takeover: Vue renders the same component tree in the browser and walks the existing DOM node by node, attaching event listeners and reactivity instead of re-creating elements. For this adoption to work, the client's first virtual DOM must match the server's HTML exactly — same tags, same text, same attributes in the same order.

When a node differs, Vue warns (Hydration node mismatch) and falls back to client-rendering that subtree — discarding server DOM and patching fresh. That fallback sounds graceful but costs the entire point of SSR (users see content swap) and can orphan state: event handlers and component state initialized against the discarded nodes misbehave, producing dead buttons and reset inputs. Text mismatches are the strictest — even a one-second timestamp difference fails the comparison.

This strictness is structural, not pedantic. Adopting DOM nodes Vue didn't create would corrupt the reactivity bookkeeping hydration exists to preserve. So the framework demands determinism: given the same props and state, server and client renders must byte-match. Every pattern below serves that single requirement — pushing nondeterminism out of the shared render path and into client-only territory.

ClockDisplay.vueJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup>
import { ref, onMounted } from 'vue'

// BAD: server and client render different seconds -> mismatch
// const now = ref(new Date().toLocaleTimeString())

// GOOD: deterministic SSR shell, live time after mount
const now = ref('--:--:--')
const mounted = ref(false)

onMounted(() => {
  mounted.value = true
  now.value = new Date().toLocaleTimeString()
})
Try it live
📊 Production Insight
Treat mismatch warnings as SSR correctness failures, not cosmetic noise. Each one means a user saw content swap or lost interactivity on first load.
🎯 Key Takeaway
Hydration adopts server DOM only when the client's first render matches exactly. Determinism isn't optional — it's structural.

Random Values and Timestamps: the Usual Suspects

Math.random() in render is the most common mismatch source: keys, IDs, variant assignments, and shuffles that differ on every evaluation. The server rolls once into HTML; the client rolls again on hydration; the trees diverge. The same applies to Date.now() and new Date() rendered as text — server time and client time always differ, and clocks plus timezones widen the gap. crypto.randomUUID() for element IDs fails identically.

Fix by resolving randomness before render or removing it from the shared path. Experiment variants belong in cookies or request headers the server reads, so both sides compute the same branch. Stable IDs come from deterministic counters (a per-request incrementing useId) rather than random strings — consistent within a render, unique within the page. Timestamps render as placeholders on the server and fill in after mount, or arrive as server-provided props both sides share.

Audit with a simple search: random, Date.now, new Date, uuid, shuffle across component files, then check whether each result executes during render or inside onMounted/handlers. Render-path hits are mismatches waiting for deploy; mounted-or-later usage is safe. This grep takes minutes and prevents the most embarrassing SSR launch failures.

ExperimentHero.vueJAVASCRIPT
1
2
3
4
5
6
7
8
9
<script setup>
// GOOD: variant resolved before render, identical on server + client
const props = defineProps({ variant: { type: String, required: true } })
</script>

<template>
  <section v-if="variant === 'b'" class="hero-b">New headline</section>
  <section v-else class="hero-a">Classic headline</section>
</template>
Try it live
📊 Production Insight
Experiment SDKs that randomize during render are hydration poison. Resolve variants in cookies or middleware before components ever evaluate.
🎯 Key Takeaway
No randomness or timestamps in the shared render path. Seed before render or defer to mount.

Browser-Only APIs Don't Exist on the Server

window, document, localStorage, matchMedia, IntersectionObserver, canvas measurement — none exist in the Node SSR context. Reading them during setup's render path throws on the server (breaking the render outright) or, when guarded sloppily, produces server output that differs from the client's real values. A theme read from localStorage renders default on the server and dark on the client: instant mismatch plus a visible flash.

The rule is absolute: browser APIs run in onMounted or event handlers, never in render-path setup code. Gate dependent markup with a mounted ref (v-if="mounted") so the server renders a deterministic shell and the client enhances after takeover. Computed properties that read browser state must likewise depend on mounted-gated refs, not on globals directly.

For widely-used browser state like viewport size or theme, centralize in a composable that returns safe SSR defaults (desktop width, light theme) until mount, then subscribes to the real values. Components consume the composable uniformly and never touch globals themselves. Verify by rendering with JavaScript disabled: the SSR shell must look intentional, not broken — skeletons and defaults, never empty holes where widgets will pop in.

ThemeToggle.vueJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
<script setup>
import { ref, onMounted } from 'vue'

const theme = ref('light') // SSR-safe default
const mounted = ref(false)

onMounted(() => {
  mounted.value = true
  theme.value = localStorage.getItem('theme') || 'light'
  document.documentElement.dataset.theme = theme.value
})
Try it live
📊 Production Insight
Theme flashes and layout jumps on load are the visible half of browser-API mismatches. SSR-safe defaults plus mounted enhancement fix both the warning and the flash.
🎯 Key Takeaway
Browser APIs belong in onMounted and mounted-gated markup. SSR renders deterministic shells, never browser-derived values.

Client-Only Wrappers and the Nuxt Pattern

Some subtrees are inherently client-only: maps, charts, editors, third-party embeds, and anything personalized that the server can't know. Rendering them on the server guarantees mismatch or wasted work. The <ClientOnly> wrapper (built into Nuxt, trivial to replicate in vanilla Vue SSR with a mounted flag) skips server rendering entirely and renders its slot only after mount, with an optional fallback slot for the SSR shell.

Use the fallback slot deliberately — a skeleton matching the widget's dimensions prevents layout shift when the client fills it in. Without dimensions, the page jumps as widgets pop into existence, trading a hydration warning for a Core Web Vitals penalty. For vanilla Vue SSR, the equivalent is v-if="mounted" around the widget plus an else skeleton: same semantics, no dependency.

Scope client-only wrappers tightly. Wrapping whole pages in <ClientOnly> surrenders SSR benefits (SEO, first paint) for the entire route. Wrap the nondeterministic leaf — the map, the personalized greeting, the timestamp — and keep everything else server-rendered. Review each wrapper with one question: could the server know this value? If yes, pass it as a prop instead of hiding behind client-only rendering.

StoreMap.vueJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
<script setup>
import { ref, onMounted } from 'vue'

const mounted = ref(false)
onMounted(() => { mounted.value = true })
</script>

<template>
  <!-- vanilla SSR equivalent of Nuxt <ClientOnly> -->
  <div v-if="mounted" class="map">Interactive map mounts here</div>
  <div v-else class="map skeleton">Loading map...</div>
</template>
Try it live
📊 Production Insight
Over-wrapped pages silently forfeit SSR: great Lighthouse interactivity scores hiding terrible first-paint and SEO. Scope <ClientOnly> to nondeterministic leaves.
🎯 Key Takeaway
Wrap inherently client-only leaves in <ClientOnly> with dimensioned fallbacks; keep the rest server-rendered.

Auth State and Personalization Without Splitting the Tree

Personalized SSR splits trees when the server renders anonymous and the client hydrates authenticated. The server, lacking the user's cookies in its API calls, renders logged-out headers and generic content; the client, holding tokens, renders the user's name and dashboard links. Every personalized node mismatches at once, producing a wall of warnings and a visible identity flash.

Fix at the data layer first: forward request cookies into SSR data fetching so the server renders as the user. In Nuxt, useRequestHeaders(['cookie']) passed to $fetch; in custom SSR, thread the incoming cookie header through server API calls. When both sides fetch as the same identity, personalization renders identically and no wrapper is needed.

When server-side identity is impossible (client-only tokens, third-party sessions), invert the pattern: render the anonymous shell deterministically on both sides, then personalize inside <ClientOnly> after mount. The shell must be the same anonymous markup the server sent — not a loading spinner that differs — so hydration succeeds and personalization enhances without a flash of wrong identity. Either strategy preserves the invariant: identical trees at takeover, divergence only after.

⚠ Forward Cookies or Render Anonymous
A server that fetches as nobody while the client hydrates as somebody guarantees mismatches on every personalized node. Either thread cookies through SSR fetching or render the anonymous shell on both sides and personalize client-only.
📊 Production Insight
Identity flashes (logged-out header swapping to logged-in) are personalization mismatches users notice and distrust. Cookie-forwarded SSR eliminates both the warning and the flash.
🎯 Key Takeaway
Server and client must render as the same user. Forward cookies, or share one anonymous shell plus client-only personalization.

Verifying the Fix: No-JS Diffs and Warning-Free Journeys

Verify hydration the way users experience it: disable JavaScript and load the page — what you see is exactly the server HTML. Re-enable, hard-reload, and compare first paint against that baseline. Any content swap, flash, or jump marks a divergence the warnings may only hint at. DevTools' view-source versus Elements comparison makes text-level differences (timestamps, IDs, variant content) visible in seconds.

Automate the check in E2E: collect console warnings during key journeys (landing, login, checkout) and assert zero hydration mismatches. Parametrize across states that split trees — logged in and out, forced experiment variants, both themes, multiple locales — since mismatches hide in state combinations, not happy paths. A journey suite green across the matrix is the only trustworthy SSR launch gate.

Monitor production continuously. Pipe hydration warnings with route and component attribution into error tracking and alert on first occurrence per release. SSR regressions arrive via innocent PRs — a timestamp in a footer, a random key in a list — and per-release alerting catches them in canary before they become dead buttons for real users. Deterministic SSR is a maintained property, not a one-time fix.

hydration.spec.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
import { test, expect } from '@playwright/test'

test('checkout hydrates without mismatch', async ({ page }) => {
  const warnings = []
  page.on('console', (msg) => {
    if (msg.text().includes('Hydration')) warnings.push(msg.text())
  })
  await page.goto('/checkout')
  await page.waitForLoadState('networkidle')
  expect(warnings).toEqual([])
  await expect(page.getByRole('button', { name: 'Pay' })).toBeEnabled()
})
Try it live
📊 Production Insight
Per-release hydration alerting catches the footer-timestamp class of regressions in canary. Without it, each innocent PR risks silent interactivity loss.
🎯 Key Takeaway
Diff no-JS source against hydrated DOM, assert zero warnings across state matrices, and alert per release.
● Production incidentPOST-MORTEMseverity: high

The A/B Test That Broke Hydration for Half of All Visitors

Symptom
After launching SSR, roughly half of all page views logged hydration mismatch warnings, and a measurable slice of users reported dead buttons — clicks did nothing until a full reload. The pattern was random per load, unaffected by browser or geography, which ruled out CDN and client-version theories. Conversion dipped on SSR-served pages while the old SPA deployment stayed clean, pointing squarely at the server-client handoff.
Assumption
The team blamed Node versus browser Intl formatting differences because mismatches clustered on price strings. They pinned locales and normalized currency rendering with no effect. Then they suspected a Vue version skew between server and client bundles, and audited the build pipeline — versions matched. Both theories chased deterministic differences while the real culprit was randomness hiding in plain sight.
Root cause
An A/B testing composable called Math.random() during setup to assign the visitor's variant and rendered different hero content per branch. The server rolled one variant into the HTML; the client rolled independently on hydration and usually got the other. Every mismatched visitor got a hydration failure: Vue discarded the server DOM for the subtree, client state reset, and event handlers bound during the failed handoff never attached — hence dead buttons until reload re-rendered everything client-side.
Fix
Variant assignment moved out of render: the server reads the experiment cookie (or assigns and sets it), and the client reads the same cookie, so both renders agree. The experiment hero is wrapped in <ClientOnly> with a deterministic fallback shell for the unassigned edge case. Random IDs across the app were replaced with a stable useId-style counter seeded per request. E2E now asserts zero hydration warnings on key journeys with forced variant cookies.
Key lesson
  • Never call Math.random() or Date.now() during render in SSR apps. Randomness must resolve before render (cookies, request seed) so server and client agree.
  • Dead-until-reload interactivity is the signature of failed hydration, not broken components. When clicks do nothing on first load, diff server HTML against client DOM first.
  • Gate SSR launches on zero hydration warnings in E2E. A warning budget of even a few per page hides per-visitor randomness that aggregates into real conversion loss.
Production debug guideFive steps to find the render source where server and client disagree.5 entries
Symptom · 01
Console shows Hydration node mismatch with a component and DOM location
→
Fix
Open the named component and list every nondeterministic render source: Math.random(), Date.now(), new Date(), crypto.randomUUID(), locale formatting, and any window/document/localStorage reads. Then disable JavaScript, reload, and view page source — that's the server output. Compare it against the hydrated DOM to see exactly which node differs.
Symptom · 02
Mismatch appears randomly — some loads fine, others broken
→
Fix
Randomness per load means a random or time-based value renders directly. Search the component tree for random IDs, experiment assignments, and timestamps rendered as text or keys. Move assignment before render (cookies, server-seeded props) or wrap the subtree in <ClientOnly> so only the client renders it.
Symptom · 03
Buttons and inputs are dead until a full reload
→
Fix
Confirm failed hydration: check for mismatch warnings on that exact load — dead interactivity plus a warning means Vue discarded the server subtree and handlers never attached. Fix the underlying mismatch rather than rebinding handlers; the dead UI is a symptom, and it resolves once both renders agree.
Symptom · 04
Mismatch only for logged-in users or personalized content
→
Fix
Compare server state versus client state: the server often renders anonymous (no cookie forwarded) while the client hydrates authenticated. Forward auth cookies to the SSR fetch layer so both render the same user, or render a deterministic shell on the server and fill personalization inside <ClientOnly> after mount.
Symptom · 05
Third-party widgets or browser APIs (matchMedia, canvas) break SSR
→
Fix
Defer all browser-only work to onMounted (which never runs on the server) and gate the widget's markup with a mounted flag or <ClientOnly>. Never read window or document during setup's render path. Verify by rendering the page with JavaScript disabled — the SSR output must be complete and sensible without the widget.
Hydration Mismatch Causes Compared
Root CauseHow to ConfirmFixPrevention
Random values or timestamps rendered directlyMismatch varies per load; view-source differs each refreshSeed before render; stable IDs; mount-gated timestampsGrep render paths for random/Date on every SSR PR
Browser-only APIs read during SSR renderThrows or default-vs-real divergence; no-JS shell emptyDefer to onMounted; SSR-safe defaults in composablesForbid window/document in setup render path by review
Server anonymous while client authenticatedWall of warnings on personalized nodes; identity flashForward cookies to SSR fetches; shared shell plus client fillE2E matrix across logged-in and logged-out states
Inherently client-only widgets server-renderedThird-party markup mismatches; canvas/media queries differScope <ClientOnly> to the leaf with dimensioned fallbackQuestion per widget: could the server know this value?
⚙ Quick Reference
5 commands from this guide
FileCommand / CodePurpose
ClockDisplay.vue<script setup>What Hydration Compares and Why Exact Match Matters
ExperimentHero.vue<script setup>Random Values and Timestamps
ThemeToggle.vue<script setup>Browser-Only APIs Don't Exist on the Server
StoreMap.vue<script setup>Client-Only Wrappers and the Nuxt <ClientOnly> Pattern
hydration.spec.jstest('checkout hydrates without mismatch', async ({ page }) => {Verifying the Fix

Key takeaways

1
Hydration demands byte-identical server and client first renders
divergence breaks takeover.
2
Keep randomness and timestamps out of render; seed before render or defer to mount.
3
Browser APIs run only in onMounted; SSR renders deterministic shells with safe defaults.
4
Scope <ClientOnly> to inherently client-only leaves with dimensioned fallbacks.
5
Render as the same user on both sides
forward cookies or share an anonymous shell.
6
Verify with no-JS diffs, warning-free E2E matrices, and per-release production alerts.

Common mistakes to avoid

5 patterns
×

Calling Math.random() or Date.now() during render

Symptom
Mismatches that vary per load; dead interactivity for a random slice of visitors.
Fix
Resolve variants via cookies before render; use seeded stable IDs; defer timestamps to mount.
×

Reading localStorage or window in setup's render path

Symptom
Server throw or default-vs-real divergence with theme/layout flashes on load.
Fix
SSR-safe defaults plus onMounted enhancement gated by a mounted flag or <ClientOnly>.
×

Wrapping entire pages in <ClientOnly> to silence warnings

Symptom
Warnings vanish but SSR benefits (SEO, first paint) disappear with them.
Fix
Scope wrappers to nondeterministic leaves with dimensioned fallbacks; keep the rest server-rendered.
×

Fetching SSR data without the request's cookies

Symptom
Personalized nodes mismatch en masse; logged-out shell flashes into logged-in content.
Fix
Forward cookie headers into SSR fetches so the server renders as the same user.
×

Launching SSR without hydration assertions in E2E

Symptom
Mismatches discovered by users through dead buttons weeks after deploy.
Fix
Assert zero hydration warnings across state matrices and alert per release in production.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is hydration and why must server and client output match?
Q02JUNIOR
Why do Math.random() and new Date() break hydration?
Q03SENIOR
How do you use browser-only APIs in an SSR app safely?
Q04SENIOR
How does work and when is it the wrong tool?
Q05SENIOR
How do you verify hydration across an app before launch?
Q01 of 05JUNIOR

What is hydration and why must server and client output match?

ANSWER
Hydration is the client adopting server-rendered DOM instead of recreating it. Vue compares its first client render against the server HTML node by node; exact match lets it attach listeners in place. Mismatches force client re-rendering, losing SSR speed and breaking handoff state.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Does hydration mismatch break the app or just warn?
02
Why does everything work in SPA dev mode?
03
Can I suppress the warning and move on?
04
DoTeleport and Suspense cause mismatches?
05
How do timezones factor into date mismatches?
06
Is content indexed by search engines?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Lessons pulled from things that broke in production.

Follow
✓ Verified
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
🔥

That's Vue. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Vue Router Navigation Guard Infinite Redirect
6 / 7 · Vue
Next
Vue Avoid Mutating a Prop Directly
→