Optimizing Nuxt apps (part 2)

This is the second part of our journey in optimizing Nuxt applications. If you haven’t checked part 1 yet, you can read it here!

Until now we have managed to migrate from a single dedicated server to a cloud infrastructure and set Github actions to build and deploy in multiple environments. On top of that, we adjusted the code in a more efficient model, where data are cached per user and the HTTP calls to the API are reduced.

As a result, we reduced operational costs both for the Nuxt apps and the APIs and have a system that can scale efficiently to accommodate any number of traffic.

So what’s next? More optimisations of course! Let’s deep dive into specific parts of the website and try to make them better.

Table of Contents

  1. The lighthouse problem
    1. Images
    2. Module preload
    3. LCP and layout shifts
  2. Extra tweaks

The lighthouse problem

After changing the server and the rendering method, we were expecting a better score but the results weren’t great.

Lighthouse score 69

There are a lot of numbers to discuss here but let’s start with something we can easily fix.

Images

Scrolling a bit down, lighthouse complains about the image formats we are serving. Specifically, it states that there are better image formats we could use, that would provide better compression.

Lighthouse image next gen issue

On top of that, it suggests using the nuxt/image package which automatically optimizes images.

Installing and configuring the package doesn’t take much. npx nuxi@latest module add image

nuxt.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export default defineNuxtConfig({
// ...
modules: ['@nuxt/image'],
$production: {
image: {
provider: 'netlify'
},
nitro: {
future: {
nativeSWR: true
},
preset: 'netlify',
netlify: {
// Images from remote URLs we want optimized
images: { remote_images: ['https://my-api.com/images/.*'] }
}
}
},
// ...
})

Using this configuration we can now use the NuxtImage component instead of img and netlify will serve our images in an optimized format.

Tip: Search and replace img tags with NuxtImage using this regular expression in VSCode
<img((.|\n)+?)><NuxtImg $1 />


Search and replace in VSCode

There are many options in this package, well documented here. An interesting one you may want to activate is lazy loading, which will only load images while they come into the viewport.

Now with the nuxt/image package in place, the results look more promising.

Lighthouse score 81

Lighthouse doesn’t complain about serving images in next-gen formats anymore, but it suggests we resize them properly. If we wanted to go to the next level NuxtImage in combination with Netlify image CDN accepts the width prop which will instruct Netlify to resize images on the fly.

Module preload

Checking the bundle output we upload into the server is always a good practice. Bundlers are awesome but they do a lot of magic we are unaware of. After inspecting the index.html we see this:

Link rel modulepreload

Nuxt pretty much instructs the browser to download and pre-load every javascript file we have. Those files are not critical for the current page but may include code needed later. This is too much and too early and lighthouse will give negative points for all these downloaded bytes.

To be honest, for a smaller web app preload everything might increase performance but in our case, it wasn’t.

Fixing this wasn’t as simple as switching a flag and documentation around it wasn’t great. Long story short, this is how we remove the modulepreload links:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
export default defineNuxtConfig({
// ...
$production: {
hooks: {
'build:manifest': (manifest) => {
for (const key in manifest) {
if (Object.hasOwnProperty.call(manifest, key)) {
// Remove all preload and prefetch tags from the HTML
const element = manifest[key]
element.prefetch = false
element.preload = false
element.dynamicImports = []
}
}
}
}
},
vite: {
$client: {
build: {
cssCodeSplit: false,
modulePreload: {
// Remove all modulePreload tags from HTML
resolveDependencies: (url, deps, context) => {
return []
}
}
}
}
},
// ...
})

LCP and layout shifts

The last two things we need to mention are the largest contentful paint element and the layout shifts. Unfortunately, Nuxt apps are not gonna get high scores in these metrics.

The root cause is how Nuxt apps are being rendered. Our code will first run on the server which will produce HTML and send it to the browser. Then on the client, Nuxt will “hydrate” the page. This is a process where our code will execute again (now on the client) and re-produce the HTML. Then the framework will check for differences between the server and client HTML, apply all the event listeners, and run anything that is meant for client-side usage only.

Now from a user’s perspective, everything looks ready from the moment we visit the page since the hydration process happens in the background. Keep in mind, that while we hydrate the page, the app is not interactive, (event listeners are not registered yet) meaning any user click or fancy animation with JS will run once the hydration process completes. Also, if there are mismatches between the server and the client HTML, these mismatches will cause layout shifts.

From the lighthouse’s perspective, hydration registers as scripting time. The tool thinks the page is not ready yet and will give negative scores during that process.

As an example, this largest contentful paint element takes around 2.5s where most of the time is attributed to render delay.

Lighthouse LCP issue

Delayed hydration is a common practice for this kind of problem. In fact, there is a plugin for nuxt to accomplish exactly that. nuxt-delay-hydration

The idea is to have HTML from the server, stripped out from any scripts. This should give a really fast first paint but zero interactivity. Once the user does any sort of interaction (mouse move, keystroke…) we immediately load the required javascript for the hydration to start.

Remember how we described the user’s perspective above. All these happen in the background and the user already sees a website seemingly ready to do everything. The main difference is how tools like lighthouse treat the delayed hydration. The trick here is that lighthouse won’t do any interaction, and as a result, the hydration will never happen. Lighthouse would think this app doesn’t need any javascript to function hence it would give an almost perfect score!

Unfortunately, https://github.com/harlan-zw/nuxt-delay-hydration didn’t work out of the box for me, and I had to fork and tweak it a bit. Feel free to use my fork if the original doesn’t work for you either.

The configuration for the delayed hydration is as follows:

yarn add @noemission/nuxt-delay-hydration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default defineNuxtConfig({
// ...
modules: [
'@noemission/nuxt-delay-hydration'
],
routeRules: {
// We use delayed hydration on the home page only
// but you can enable it in other routes as well
'/': {
prerender: true,
delayHydration: 'init'
},
}
// ...
})

The result? An almost perfect lighthouse score!

Lighthouse score 100

Extra tweaks

  • Web fonts are often a weak point in terms of layout shifts. These fonts are crucial when rendering text but often they won’t load in time. A quick solution is to use font-display: optional; which will instruct the browser to use the system’s font if the desired font cannot load in a short period. In action, the user will see the system’s font the first time they visit the page and the desired one on any subsequent visit since the font will have been cached. If you want to experiment more on the web fonts check this awesome article from web.dev

  • Inline SVGs are also something to consider to speed up things. Instead of having the browser asynchronously load a critical SVG, we could hard-code it in the HTML. Be careful to not overdo that though, as inline SVGs will significantly increase the initial page size and load.

  • Code splitting and async imports are always important, and most probably the first thing we do when we need to speed up things. With the new era of bundlers like vite is super easy to do that. So be sure you are putting big libraries into separate chunks and you also load asynchronously things you know they won’t be needed immediately. For example, consider the following:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // Before
    import modal from 'modalLibrary' // modalLibrary will be bundled together with our code
    function onBtnClick(){
    modal.open('...')
    }

    // After
    function onBtnClick(){
    // Import modalLibrary asynchronously after some user interaction
    // and when is absolutely necessary
    // Tip: Make sure you are giving the user feedback that the click was registered (maybe put the button into loading state)
    import('modalLibrary')
    .then(modal => {
    modal.open('...')
    })
    }
  • Nuxt server-only components and pages are some really cool new features of the framework. We have the option to make some pages or components run only on the server. As the docs state those will be served with zero client javascript and if the user navigates or requests them, an HTTP call will happen to fetch from the server. A benefit I see in terms of optimization is the ability to not ship huge libraries to the client. For example, a page that includes a library to render markdown can be declared server only and save tons of bytes by not shipping the heavy markdown renderer. You can read more about them here.