As web apps continue to grow in complexity and size, so do performance concerns. Developers often address this by adopting server-side rendering (SSR) to offload some of the rendering processes from the client.
However, site performance can still take a hit even when HTML rendering happens on the server. While HTML is delivered in a fast and SEO-friendly manner, the process of hydration â making the app interactive on the client-side â can be costly. In turn, metrics like Time to Interactive (TTI) and Estimated Input Latency (EIL) can plummet for apps with complex, deeply-nested HTML.
Now, you could solve this with techniques like code-splitting, or loading the vital parts of the app immediately while delaying the delivery of code, and hydrate the other components. This might improve your metrics but will still waste load time on components the user never sees or interacts with.
Thatâs where lazy hydration steps in. Letâs see what that is, how it works, and how to implement it in Vue 3.
Partial hydration vs. lazy hydration
To understand the variants of hydration and how they work, you first need to familiarize yourself with partial hydration.
As the name implies, in partial hydration, you hydrate only certain parts of your app. This is useful when implementing so-called âislands architectureâ, where different app sections are considered separate entities. This makes each section of the app function independently from the others, which allows them to hydrate separately.
Letâs think about how partial hydration and islands architecture would apply to a website like a blog. You could hydrate the interactive parts like the toolbar and comment section, but leave other parts like the content itself completely static. Such an approach improves your websiteâs performance and UX, and no resources are wasted on static content, making the interactive parts hydrate faster.
Lazy hydration builds upon the concept of partial hydration and takes them even further. The concept is similar in implementation for any framework that has SSR, basic hydration, and async components already included.
Instead of only being able to decide what parts of the web app should be hydrated, you can also decide when that should happen. For example, you can hydrate the component only when idle, when itâs in the viewport, or in response to various other triggers like Promise
resolving or user interaction.
This takes resource-saving and performance optimizations to another level. You no longer have to hydrate components the user will never see or interact with, making TTI nearly instantaneous!
Lazy hydration with Vue
Vue 2 had a great, fairly popular library named vue-lazy-hydration. It provides a renderless LazyHydrate
component and a bunch of manual function wrappers like hydrateWhenVisible
for wrapping the components you want to lazy-hydrate. It also allows you to hydrate on different conditions, such as:
- when the browserâs idle (with
requestIdleCallback
) - when the componentâs within the viewport (with
IntersectionObserver
) - on the userâs interaction (
click
,mouseover
, etc.) - with a manual trigger (
Promise
, boolean switch, etc.) never
(for static, SSR-only components)
Sadly, at the time of publication, this, nor any other prominent lazy hydration library doesnât support Vue 3. With that said, the vue-lazy-hydration support for Vue 3 is in development and it appears there is a plan to release after Nuxt 3 comes out.
This leaves us to either continue using Vue 2 for lazy hydration, or to implement our own mechanism, which is what weâre going to do in this article.
Implementing lazy hydration in Vue 3
With UI frameworks like Vue that have inbuilt SSR and hydration support, implementing lazy hydration is rather easy.
Youâll need a wrapper or renderless component that automatically renders your component on the server while using conditional rendering on the client-side to delay hydration until certain conditions are met.
I decided to base our implementation of Vue 3 lazy hydration upon react-lazy-hydration. Its code is simpler than vue-lazy-hydrationâs and is surprisingly more translatable, with React Hooks converting nicely with the Vue Composition API.
Component declaration and props
We start with a base Vue 3 component, with additional TypeScript inclusion and an isBrowser
utility function for checking whether browser globals are available.
<script lang="ts"> import { defineComponent, onMounted, PropType, ref, watch } from "vue"; type VoidFunction = () => void; const isBrowser = () => { return typeof window === "object"; }; export default defineComponent({ props: {}, setup() {}, }); </script> <template></template>
Our lazy hydration wrapper will include similar functionality to what the previously mentioned libraries provide. For that, weâll have to accept a fairly broad set of config props.
// ... export default defineComponent({ props: { ssrOnly: Boolean, whenIdle: Boolean, whenVisible: [Boolean, Object] as PropType< boolean | IntersectionObserverInit >, didHydrate: Function as PropType<() => void>, promise: Object as PropType<Promise<any>>, on: [Array, String] as PropType< (keyof HTMLElementEventMap)[] | keyof HTMLElementEventMap >, }, // ... }); // ...
With the above props, weâll support SSR-only static components as well as hydrating when the browser is idle, the component is visible, or after the given Promise
resolves.
On top of that, on
will support hydrating on user interaction, while didHydrate
will allow for a callback after the componentâs hydrated.
Setup function
In setup
, we first initialize a few required values.
// ... export default defineComponent({ // ... setup() { const noOptions = !props.ssrOnly && !props.whenIdle && !props.whenVisible && !props.on?.length && !props.promise; const wrapper = ref<Element | null>(null); const hydrated = ref(noOptions || !isBrowser()); const hydrate = () => { hydrated.value = true; }; }, }); // ...
Weâll use a wrapper
template ref for accessing the wrapper element and a hydrated
ref for holding the reactive boolean value, which determines the current state of hydration.
Note how we initialize the hydrated
ref. When there are no options set, the component will be hydrated immediately by default. Otherwise, the hydration will be delayed on the client-side while going through SSR.
More great articles from LogRocket:
- Don't miss a moment with The Replay, a curated newsletter from LogRocket
- Learn how LogRocket's Galileo cuts through the noise to proactively resolve issues in your app
- Use React's useEffect to optimize your application's performance
- Switch between multiple versions of Node
- Discover how to animate your React app with AnimXYZ
- Explore Tauri, a new framework for building binaries
- Advisory boards arenât just for executives. Join LogRocketâs Content Advisory Board. Youâll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
hydrate
is just a one-way helper function for setting hydrated
to true
.
Hydration callback registration
Next up, we start creating the logic, with an onMounted
callback and a single watch
effect.
// ... onMounted(() => { if (wrapper.value && !wrapper.value.hasChildNodes()) { hydrate(); } }); watch( hydrated, (hydrate) => { if (hydrate && props.didHydrate) props.didHydrate(); }, { immediate: true } ); // ...
In the onMounted
callback, we check whether the element has any children. If not, we can hydrate immediately.
The watch
effect handles the didHydrate
callback. Notice the immediate
option â itâs important for when hydration isnât delayed, both during SSR and when no options are provided.
Setting the primary watch
effect
Now, we get into the primary watch
effect that will handle all the options and set hydrated
ref appropriately.
// ... watch( [() => props, wrapper, hydrated], ( [{ on, promise, ssrOnly, whenIdle, whenVisible }, wrapper, hydrated], _, onInvalidate ) => { if (ssrOnly || hydrated) { return; } const cleanupFns: VoidFunction[] = []; const cleanup = () => { cleanupFns.forEach((fn) => { fn(); }); }; if (promise) { promise.then(hydrate, hydrate); } }, { immediate: true } ); // ...
The effect will trigger changes in props, as well as in the wrapper
and hydrate
refs.
First, we check if the component is meant to render only on the server-side or if it has already been hydrated. We do this because, in either of these cases, thereâs no need to evaluate the effect further, so we can return
from the function.
If the process continues, we initialize the cleanup function for when the effect is invalidated, and handle the Promise
-based lazy hydration.
Visibility-based hydration
Next, still inside the effect, we handle the visibility-based hydration. If the IntersectionObserver
is supported, we initialize it, passing either the default or provided options. Otherwise, we hydrate immediately.
// ... if (whenVisible) { if (wrapper && typeof IntersectionObserver !== "undefined") { const observerOptions = typeof whenVisible === "object" ? whenVisible : { rootMargin: "250px", }; const io = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting || entry.intersectionRatio > 0) { hydrate(); } }); }, observerOptions); io.observe(wrapper); cleanupFns.push(() => { io.disconnect(); }); } else { return hydrate(); } } // ...
Note the cleanup callback for disconnecting the IntersectionObserver
instance from the wrapper
element.
Browser idle-based hydration
We follow a similar structure for browser idle-based hydration, this time with requestIdleCallback
and cancelIdleCallback
.
if (whenIdle) { if (typeof window.requestIdleCallback !== "undefined") { const idleCallbackId = window.requestIdleCallback(hydrate, { timeout: 500, }); cleanupFns.push(() => { window.cancelIdleCallback(idleCallbackId); }); } else { const id = setTimeout(hydrate, 2000); cleanupFns.push(() => { clearTimeout(id); }); } }
requestIdleCallback
cross-browser compatibility is lower than 80 percent, notably with no support from Safari on both iOS and macOS, so weâll have to implement a fallback with setTimeout
, delaying the hydration and pushing it to the async queue.
If youâre using TypeScript, you should note that, currently, you wonât find requestIdleCallback
in the default lib. For proper typing, youâll need to install @types/requestidlecallback.
User interaction-based hydration
Lastly, we handle user event-based hydration. Here, things are relatively simple as we just loop through events and set event listeners accordingly.
if (on) { const events = ([] as Array<keyof HTMLElementEventMap>).concat(on); events.forEach((event) => { wrapper?.addEventListener(event, hydrate, { once: true, passive: true, }); cleanupFns.push(() => { wrapper?.removeEventListener(event, hydrate, {}); }); }); } onInvalidate(cleanup);
After that, remember to call onInvalidate
to register the cleanup function, and the effect is ready!
Finishing up the template
To finish off the component, return the refs needed in the template from the setup
function.
// ... export default defineComponent({ // ... setup() { // ... return { wrapper, hydrated, }; }, }); // ...
Then, in the template, render the wrapping <div>
, assign refs, and conditionally render the component for lazy hydration.
<template> <div ref="wrapper" :style="{ display: 'contents' }" v-if="hydrated"> <slot></slot> </div> <div ref="wrapper" v-else></div> </template>
Using our lazy hydration component
With our lazy hydration component ready, itâs time to test it out!
Scaffolding our Vue 3 SSR app
First, youâll need to set up your environment that is either SSR- or Static Site Generator (SSG)-ready. Technically, anything with pre-rendered HTML and Vue 3 with hydration enabled should work, but your mileage may vary.
As neither Nuxt.js nor Gridsome is compatible with Vue 3 just yet, your best bet would be to go with something like vite-plugin-ssr. Such a solution will allow you to take advantage of the great development experience Vite provides while implementing SSR without much trouble.
You can scaffold a new vite-plugin-ssr app with the following command:
npm init [email protected]
Then, set up the lazy hydration component, either with the guide above or from this GitHub Gist.
With that in place, go to any available page, wrap an interactive component inside <LazyHydrate>
and play with it!
<template> <h1>Welcome</h1> This page is: <ul> <li>Rendered to HTML.</li> <li> Interactive. <LazyHydrate when-visible><Counter /></LazyHydrate> </li> </ul> </template> <script lang="ts"> import Counter from "./_components/Counter.vue"; import LazyHydrate from "./_components/LazyHydrate.vue"; export default { components:{ Counter, LazyHydrate, } }; </script>
Use different options, see when the componentâs interactive, check out when itâs hydrated with the didHydrate
callback, and more!
Combining lazy hydration with async components
To further improve your appâs TTI metrics and loading times, you can combine lazy hydration with async components. This will split your app into smaller chunks, ready to be loaded on-demand. With that, your lazy-hydrated components will only load when the hydration happens.
import { defineAsyncComponent } from "vue"; import LazyHydrate from "./_components/LazyHydrate.vue"; export default { components: { Counter: defineAsyncComponent({ loader: () => import("./_components/Counter.vue"), }), LazyHydrate, }, };
Keep in mind that youâll have to be careful with this approach, as dynamically fetching components might create a noticeable delay for the user. In this case, youâll have to be selective about which components to defer and will need to implement fallback content, like loaders for when the code is fetched and parsed.
However, even with all of that to consider, lazy-hydrated async components can still have great potential for drastically improving the performance of large and complex apps, especially those that heavily rely on elements such as interactive graphs or hidden dialogs.
Bottom line
So there you have it â lazy hydration explained and implemented in Vue 3! With the component implemented in this post, you can optimize your SSR/SSG app, improve its performance, responsiveness, and user experience.
For the complete code of the <LazyHydrate>
component, check out this GitHub Gist. Feel free to experiment with it. If you’ve got any ideas for improvement, let me know over on GitHub.
Be sure to follow updates on vue-lazy-hydration. The next version is said to take advantage of new Vue 3-Node APIs and thus is likely to be more performant or provide more features than the simple implementation from this post.
Experience your Vue apps exactly how a user does
Debugging Vue.js applications can be difficult, especially when there are dozens, if not hundreds of mutations during a user session. If youâre interested in monitoring and tracking Vue mutations for all of your users in production, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens in your Vue apps including network requests, JavaScript errors, performance problems, and much more. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred.
The LogRocket Vuex plugin logs Vuex mutations to the LogRocket console, giving you context around what led to an error, and what state the application was in when an issue occurred.
Modernize how you debug your Vue apps - Start monitoring for free.