Vue Hydration Mismatch: SSR HTML Differs From Client
Random values and browser-only APIs split server HTML from client DOM.
20+ years shipping production backend systems. Lessons pulled from things that broke in production.
- ✓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
- 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
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.
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.
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.
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.
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.
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.
The A/B Test That Broke Hydration for Half of All Visitors
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.- Never call
Math.random()orDate.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.
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.| File | Command / Code | Purpose |
|---|---|---|
| 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.js | test('checkout hydrates without mismatch', async ({ page }) => { | Verifying the Fix |
Key takeaways
Common mistakes to avoid
5 patternsCalling Math.random() or Date.now() during render
Reading localStorage or window in setup's render path
Wrapping entire pages in <ClientOnly> to silence warnings
Fetching SSR data without the request's cookies
Launching SSR without hydration assertions in E2E
Interview Questions on This Topic
What is hydration and why must server and client output match?
Frequently Asked Questions
20+ years shipping production backend systems. Lessons pulled from things that broke in production.
That's Vue. Mark it forged?
5 min read · try the examples if you haven't