Skip to the solution
HardFrontend12 min read

What is lazy hydration in Vue 3.5 — hydrate strategies (idle, visible, interaction)?

264 views

The problem

Question presented to candidate: "Our server-rendered product page is visible quickly, but it takes a long time before anything responds to a tap, because every widget hydrates on load. Vue 3.5 added lazy hydration. Explain how it works, which strategies exist, and what it does and does not save us."

What a strong answer should cover:

  • Lazy hydration is configured per async component with the hydrate option of defineAsyncComponent, and only applies when hydrating SSR markup.
  • The built-in strategies: hydrateOnIdle(timeout), hydrateOnVisible(observerOptions), hydrateOnInteraction(events) and hydrateOnMediaQuery(query), plus custom strategies with the (hydrate, forEachElement) => teardown signature.
  • Until it hydrates, the component is static server HTML: visible, but its listeners are not attached.
  • hydrateOnInteraction replays the triggering event after hydrating; it needs an explicit event list in plain Vue.
  • In plain Vue the loader still runs at mount: the strategy defers hydration work, not the download. Nuxt's hydrate-on-* props wrap it differently.
  • A prop change from the parent forces immediate hydration.

Clarifying questions expected:

  • "Which widgets are below the fold or rarely used?"
  • "Are we on plain Vue SSR or Nuxt? The ergonomics and what gets deferred differ."

Code / implementation expected: Optional — defineAsyncComponent({ loader, hydrate: hydrateOnVisible() }), or a small custom strategy.

  • lazy-hydration
  • ssr
  • performance

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

12 min read · 12 sections

Read the code

All four built-in strategies on SSR-rendered async components (run in Chromium)
import { createSSRApp, createApp, defineAsyncComponent, defineComponent, h, ref, onMounted,
  hydrateOnIdle, hydrateOnVisible, hydrateOnInteraction, hydrateOnMediaQuery } from "vue";

const log = (msg) => (typeof window !== "undefined" ? console.log(`[${(performance.now() | 0)}ms] ${msg}`) : null);

// One small interactive widget, reused by every strategy.
const Widget = (name) => defineComponent({
  setup() {
    const clicks = ref(0);
    onMounted(() => log(`${name}: hydrated`));
    return () => h("button", { id: name, onClick: () => clicks.value++ }, `${name} clicks: ${clicks.value}`);
  },
});

const lazy = (name, hydrate) => defineAsyncComponent({
  loader: () => { log(`${name}: loader called`); return Promise.resolve(Widget(name)); },
  hydrate,
});

const Idle = lazy("idle", hydrateOnIdle(2000));
const Visible = lazy("visible", hydrateOnVisible({ rootMargin: "0px" }));
const Interaction = lazy("interaction", hydrateOnInteraction(["click", "focus"]));
const Media = lazy("media", hydrateOnMediaQuery("(max-width: 500px)"));
const Eager = lazy("eager", undefined);

const App = defineComponent({
  setup() {
    onMounted(() => log("root: hydrated (app.mount returned)"));
    return () => h("main", [
      h(Eager), h(Idle), h(Interaction), h(Media),
      h("div", { style: "height:3000px" }, "spacer"),
      h(Visible),
    ]);
  },
});

export const makeApp = (ssr = true) => (ssr ? createSSRApp(App) : createApp(App));
A custom strategy: hydrate on hover intent
// A custom hydration strategy: hydrate only when the pointer rests on the
// component for `delay` ms (hover intent), so a quick fly-over does nothing.
// Signature: (hydrate, forEachElement) => optional teardown
export const hydrateOnHoverIntent = (delay = 100) => (hydrate, forEachElement) => {
  let timer;
  const enter = () => { timer = setTimeout(done, delay); };
  const leave = () => clearTimeout(timer);
  const done = () => { teardown(); hydrate(); };
  const teardown = () => {
    clearTimeout(timer);
    forEachElement((el) => {
      el.removeEventListener("pointerenter", enter);
      el.removeEventListener("pointerleave", leave);
    });
  };
  // forEachElement visits every root element of the server-rendered component
  forEachElement((el) => {
    el.addEventListener("pointerenter", enter);
    el.addEventListener("pointerleave", leave);
  });
  return teardown; // Vue calls it if the component unmounts before hydrating
};

// Usage:
// const PriceChart = defineAsyncComponent({
//   loader: () => import("./PriceChart.vue"),
//   hydrate: hydrateOnHoverIntent(150),
// });

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 88 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextHard

What is a custom renderer in Vue (createRenderer)?

Continue