RenderloriaRender · Measure · Refine Request a rendering review

Guide

Vue and Nuxt hydration: mismatches and lazy hydration

Server-rendered Vue and Nuxt apps face two hydration problems: mismatches, where the client disagrees with the server's HTML, and cost, where hydrating everything at once blocks the main thread. This guide covers both, with the built-in tools each framework now provides.

How hydration works in Vue

In a server-rendered Vue app, the server renders the component tree to HTML. In the browser, Vue mounts the same app in hydration mode: instead of creating DOM nodes, it walks the existing ones, checks that they match what the components would render, and attaches event listeners and reactivity. If everything matches, the page becomes interactive without rebuilding the DOM. If something does not match, Vue reports a hydration mismatch.

Nuxt runs this process for you: it renders pages on the server (or at build time), serializes the data the page needs into the HTML payload, and hydrates on the client.

Part one: hydration mismatches

What a mismatch costs

In development, Vue logs a warning naming the mismatched node. To recover, it patches the DOM to match the client render. That patching is extra work at the worst moment, and if the difference is visible, users see content flicker or jump. Mismatches in text are mostly a correctness problem; mismatches in structure can cause more patching and occasionally broken interactivity in the affected subtree.

Fix mismatches before measuring hydration performance. A route that patches itself on every load gives misleading traces.

Common causes and fixes

Time and dates. The server formats a date in its own time zone and locale, the browser in the user's. Format on one side only: either pass a pre-formatted string from the server, or render a neutral placeholder on the server and fill in the local time after mount. Where a difference is expected and harmless, Vue 3.5 added a data-allow-mismatch attribute that suppresses the warning, optionally for one kind of mismatch such as text or attribute (source 3). Use it sparingly; it hides the warning, not the patching.

Random values and generated IDs. Math.random() or a counter-based ID generator gives different values on each side. Use framework helpers for stable IDs; Vue 3.5 added useId(), which produces IDs that stay stable across server and client renders (source 3).

Browser-only checks during render. Code that reads window, localStorage, matchMedia or screen width while rendering produces different output on the server, where those do not exist. Move such reads into onMounted and render a server-safe default first. In Nuxt, the <ClientOnly> component renders its content only in the browser, with an optional fallback for the server.

Invalid HTML nesting. The browser's parser silently fixes invalid nesting, such as a div inside a p or a block inside an a that contains interactive elements. The DOM then differs from what Vue expects. Validate templates and keep nesting valid.

Data that changes between render and hydration. If the client fetches fresh data instead of reusing the server's payload, the two can disagree. In Nuxt, use useFetch or useAsyncData, which transfer the server's result in the payload so the client hydrates with the same data.

Extensions and third-party scripts. Browser extensions or tag managers that modify the DOM before hydration cause mismatches you cannot fix in your code. Test in a clean profile to tell these apart from real bugs.

Finding mismatches

Run the app in development mode and open the console: Vue names the node and the expected versus actual content. For production builds, Vue provides a compile-time flag (__VUE_PROD_HYDRATION_MISMATCH_DETAILS__) that keeps detailed mismatch messages; enable it in a staging build when a mismatch only appears outside development.

Part two: hydrating less

Once hydration is correct, look at its cost. Record a Performance trace on a throttled CPU and look at the long tasks after the main bundle runs. On content-heavy pages, much of that time goes to components that are never interacted with or only appear after scrolling.

Vue 3.5 lazy hydration

From Vue 3.5, async components in server-rendered apps can specify when to hydrate (source 1). The built-in strategies are imported individually so unused ones can be tree-shaken:

  • hydrateOnIdle() hydrates through requestIdleCallback, with an optional maximum timeout.
  • hydrateOnVisible() hydrates when the component's elements become visible, using IntersectionObserver; it accepts observer options such as rootMargin.
  • hydrateOnMediaQuery('(max-width:500px)') hydrates when a media query matches.
  • hydrateOnInteraction('click') hydrates when the given events fire on the component's elements, and replays the triggering event once hydration completes.
import { defineAsyncComponent, hydrateOnInteraction, hydrateOnIdle } from 'vue'

// A rarely used filter panel: hydrate when someone clicks or focuses it
const FilterPanel = defineAsyncComponent({
  loader: () => import('./FilterPanel.vue'),
  hydrate: hydrateOnInteraction(['click', 'focusin'])
})

// A newsletter box in the footer: hydrate when the browser is idle
const FooterSignup = defineAsyncComponent({
  loader: () => import('./FooterSignup.vue'),
  hydrate: hydrateOnIdle(2000)
})

Custom strategies are also possible, using the HydrationStrategy type (source 1). This section of the Vue guide applies only to server-side rendering (source 1).

Nuxt delayed hydration

Nuxt builds on this with props on Lazy components. Prefixing a component with Lazy loads its code lazily, but lazy components still load eagerly unless they are conditionally rendered, so the hydration strategies are what delay interactivity (source 2). The available props are (source 2):

  • hydrate-on-visible: when the component enters the viewport.
  • hydrate-on-idle: when the browser is idle, optionally with a maximum timeout.
  • hydrate-on-interaction: after a given event, defaulting to pointerenter, click and focus.
  • hydrate-on-media-query: when the window matches a media query.
  • hydrate-after: after a delay in milliseconds.
  • hydrate-when: when a boolean condition becomes true.
  • hydrate-never: the component stays static.
<template>
  <article>
    <ProductSummary :product="product" />
    <LazyProductReviews hydrate-on-visible :product-id="product.id" />
    <LazySiteFooter hydrate-never />
  </article>
</template>

The Nuxt documentation lists some limits: delayed hydration works only in single-file components, props must be written in the template rather than spread with v-bind, it does not apply to direct imports from #components, and a prop change on a lazily hydrated component triggers hydration immediately (source 2). Components emit a @hydrated event when they hydrate (source 2).

Choosing what to delay

Good candidates:

  • Components below the fold on most viewports, such as reviews, related items and footers.
  • Widgets used by a minority of visitors, such as filters on mobile or a chat launcher.
  • Purely presentational components that do not need interactivity at all (hydrate-never).

Poor candidates:

  • Navigation, search boxes and primary buttons that people use immediately. Delaying them adds latency to the first interaction.
  • Components whose hydrated output differs from their server output, which would shift layout when they hydrate.
  • Components whose behaviour other code depends on at start-up.

When a delayed component holds a control, make sure it either works as plain HTML before hydration or is hidden until it works. A button that looks ready but ignores taps is worse than a short wait.

Measuring the effect

Compare traces on a throttled CPU before and after: the long tasks after bundle evaluation should shrink, and the gap between first paint and the end of hydration should narrow. In the field, look at INP input delay for interactions early in the page's life, and at LCP if the change also reduced work before the main content painted.

Sources

  1. Async Components: Lazy Hydration, Vue.js guide Primary source (Vue team)
  2. Components directory: delayed hydration, Nuxt docs Primary source (Nuxt team)
  3. Announcing Vue 3.5, The Vue Point (September 1, 2024) Primary source (Vue team)

Ready when your team is

Plan your rendering review with us

Tell us about your app, its framework and where it feels slow. We reply with questions and a fixed written quote.

Start a conversation
  • Fixed written quote
  • Written findings
  • No obligation