Optimize Responsive Application Performance in Nuxt 3

Since web 2.0, web applications are built using responsive design to serve the same page on different devices. Responsive design has its benefits, with one codebase we can render UI for screen sizes on different devices. And it also helps in decreasing the immediate need for mobile applications. Thus teams can quickly launch their applications and iterate on them, based on early user feedback.

With all these benefits, it brings in some complexities as well. One of such complexities is building some components more than once to support different screen sizes. But if we are not careful while designing these responsive components, the page performance can degrade drastically due to excessive DOM size.

Let’s understand the problem statement

It’s straightforward to show appropriate components using CSS as shown in the following example. Just for the note, I am using Tailwind CSS.

<div class="hidden lg:block">
  <ComponentWeb />
</div>
<div class="block lg:hidden">
  <ComponentMobile />
</div>

The above code will hide the first component on mobile and display the second component and vice versa on desktop. This practice is used widely as it saves development time and effort.

Now, let’s take an example use case where we will face challenges if we take the above approach. Consider a complex component that needs to render completely differently for mobile and desktop. A lot of us will implement this component with the above-mentioned approach.

But behind the scenes, the rendered page will consume more browser resources. As both the components are served to the end user, it will increase the page DOM size. And we all know that a big DOM size degrades page performance, starting from the initial paint to performing DOM operations later.

The first solution that comes to our mind is

Let’s render the appropriate component required for the end user’s screen size. The following pseudo-code checks the device type and renders the appropriate component.

{{ if device is desktop }}
<ComponentWeb />
{{ else }}
<ComponentMobile />
{{ end }}

In Vue.js we have to use the v-if directive for conditional component rendering. In the following example, the user’s browser screen size is decided based on the 'user-agent' header received in the HTTP request object.

<div v-if="deviceType == 1">
  <ComponentWeb />
</div>
<div v-if="deviceType == 2">
  <ComponentMobile />
</div>
<script setup>
  const deviceType = ref(null);
  const isMobile =
    /(Android|webOS|iPhone|iPad|iPod|tablet|BlackBerry|Mobile|Windows Phone)/i.test(
      headers['user-agent']
    );
  if (isMobile) {
    deviceType = ref(2);
  } else {
    deviceType = ref(1);
  }
</script>

At first glance, the above Nuxt 3 code looks perfectly fine. And it will also work for desktop browsers but will fail for mobile browsers. For mobile browsers, we will see hydration errors and the component won’t render. That is because the server-side rendered Vue.js applications execute on both the server and the client side. Let’s understand it step by step.

For better understanding, the above code has an extra variable isMobile. When the page is rendered for the mobile browser, the isMobile variable is set to true on the server side, thus the deviceType variable is set to  2. So, the server-generated HTML code will only have ComponentMobile.

Now during the rehydration process when the browser starts re-evaluating the conditions, as request headers are not present, the isMobile will be set to false. So, the code will remove ComponentMobile and try to show ComponentWeb. Which will result in a hydration error and the component won’t render.

So, how will we make it happen?

Our solution will work if Nuxt 3 code can somehow remember the 'user-agent' value on the browser side. This is where Nuxt 3 useState composable will come to the rescue. The above Nuxt 3 code is rewritten using useState.

<div v-if="deviceType == 1">
  <ComponentWeb />
</div>
<div v-if="deviceType == 2">
  <ComponentMobile />
</div>
<script setup>
const checkDeviceType = () => {
  const headers = useRequestHeaders();
  if (
    /(Android|webOS|iPhone|iPad|iPod|tablet|BlackBerry|Mobile|Windows Phone)/i.test(
      headers['user-agent']
    )
  ) {
    return ref(2);
  } else {
    return ref(1);
  }
};

const deviceType = useState(checkDeviceType);
</script>

Nuxt 3 useState preserves the value after server-side rendering and using a unique key it is shared across all components. Thus the server-side derived value of the deviceType variable will be preserved for rehydration on the browser side.

Few important things to note:

  • Do not assign classes, functions, or symbols (Non-serializable code) using useState as it serializes data using JSON.
  • The useState composable only works during Lifecycle Hooks or setup.

The above Nuxt 3 code will render ComponentMobile for mobile and ComponentWeb for desktop, as intended.

Sometimes, complex problems have really simple solutions.

BTW, a big shoutout to the fantastic Nuxt GitHub community. This community is super active and always ready to help.