Optimising a Vuetify app

4 minute read

Background

I’ve been building No Cookie Analytics in Vue using the Vuetify framework. Most of my time was spent hacking away without any due regard to performance. I looked at the PageSpeed Insights score, which handed out an abysmal score of 29 (out of 100). It was not shocking, I hadn’t spent any time on this part. The app was sluggish and I knew I had to fix it. Here’s my before and after scores with the steps I took to optimise the app:

Before score to After score

This screenshot is of the mobile score. (The desktop score is a perfect 100).

Here’s how I optimised the app.

1. Import only used icons

The default Vuetify setup bundles the whole material design icons CSS, a dependency clocking in at over 250 KB. For only a tiny subset of the icons I was using, this was the first thing to be removed.

Default

<!-- bad -->
<link
    href="https://cdn.jsdelivr.net/npm/@mdi/font@5.x/css/materialdesignicons.min.css"
    rel="stylesheet"
>

Better (import only used icons)

Instead use @mdi/js and include icons in global Vue instance piecemeal like this:

// Initialising Vuetify
import { mdiClose } from '@mdi/js';

export default new Vuetify({
  icons: {
       close: mdiClose,
	   ...
	}
});

// In component template
<template>
	<v-icon> {{ $vuetify.icons.values.close }}</v-icon>
</template>

And that’s it for registering icons and using them globally.

But if you are, say, using 100 icons across your whole app but only on a few pages it doesn’t make sense to bundle all into the main chunk. In that case it’s better to import icons a la carte within each module and use it only for that module like this:

// Vue Component
<template>
  <v-icon>{{ svgPath }}</v-icon>
</template>

<script>
  import { mdiAccount } from '@mdi/js'

  export default {
    data: () => ({
      svgPath: mdiAccount
    }),
  }
</script>

2. Get rid of moment.js locales

Moment.js has been superseded by better alternatives which support tree-shaking, but some packages might still need it. I couldn’t get rid of moment.js altogether, but I could disregard all locales that were always bundled with moment.js.


// vue.config.js

const webpack = require('webpack');

module.exports = {
  configureWebpack: {
    plugins: [
      new webpack.IgnorePlugin({
        resourceRegExp: /^\.\/locale$/,
        contextRegExp: /moment$/,
      }),
    ],
  },

3 Setup tree-shaking for vuetify

The default vuetify setup, again, bundles everything into the final build even if a component isn’t used. The solution here is to setup tree-shaking, documented on their website.

// Before
import Vuetify from 'vuetify';
// After
import Vuetify from 'vuetify/lib';

Add vuetify loader

// vue.config.js
const VuetifyLoaderPlugin = require('vuetify-loader/lib/plugin');

module.exports = {
  configureWebpack: {
    plugins: [
      new VuetifyLoaderPlugin(),
	  ...
    ]
  }
};

4 Add gzip and cache headers

My docker image exposes static assets with a nginx server, which has no compression enabled by default. I modified the nginx configuration to compress these assets:

gzip on;
gzip_vary on;
gzip_min_length 10240;
gzip_proxied expired no-cache no-store private auth;
gzip_http_version 1.1;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/x-javascript application/xml image/png;
gzip_disable "MSIE [1-6]\.";

location /static {
  root /usr/share/nginx/html;
  expires 1y;
  add_header Cache-Control "public";
}

This is only gzip compression, there are third party nginx docker images out there which also support brotli, a more efficient compression algorithm.

5 Resize big images

Simple one: I had a png original which was 1500x1500px weighing in at 500 KB, which I scaled to 100x100px, resulting in an almost negligible image of just a couple of kilobytes.

6 Replace huge modules

Replace huge modules. Use yarn build --report and check dist/report.html in browser. I replaced these components:

  • Date picker, which pulled in moment.js. This was replaced with another package which is self contained and didn’t depend on another date library.
  • vue-charts, a package based on Chart.js 2.x, which doesn’t have tree shaking. Bundling a fully featured chart library when all I needed was line charts was superfluous. uPlot provides a much leaner alternative.
  • Instead of bundling all country flags into the build, I serve them as static assets from the public directory which can be included as images wherever needed.

7 Optimise CSS using cssnano

cssnano optimises CSS to a semantically equivalent result, but with unnecessary fluff removed. I added this to my vue.config.js to get cssnano to optimise my built CSS.

// vue.config.js

  chainWebpack: (config) => {

    config.module
      .rule('css')
      .oneOf('normal')
      .use('postcss-loader')
      .tap((options) => {
        options.plugins.unshift(require('cssnano'));
        return options;
      });
  }

8 Optimise CSS with purgeCSS (part 2)

PurgeCSS removes unused CSS. Because it’s a “destructive” tool, it’s not straightforward to configure as without a proper ruleset it’s easy to remove CSS which the app needs. But this is the only way to drastically reduce the CSS size. Initially I copied a purgecss configuration from another project, but it turned out to be not as efficient as it still bundled a few things I didn’t need. So I researched a better purgecss config (link to my purgecss configuration) matching my needs.

9 Blacklist used chunks from being prefetched

The vue app included prefetch links for all Javascript chunks of the app. This wasn’t ideal so I blacklisted them all so these will be loaded on demand:

chainWebpack: (config) => {
  if (config.plugins.has('prefetch')) {
     config.plugin('prefetch').tap((options) => {
        options[0].fileBlacklist = options[0].fileBlacklist || [];      
        options[0].fileBlacklist.push(/\/users-.*/);
        options[0].fileBlacklist.push(/.*map$/);
        return options;
    });
  }
}

Conclusion

While I managed to achieve some significant improvement in the page speed score, it could still be better. I didn’t want to sink a lot of time into this at this point where the last set of improvements would only have been diminishing returns, so I stopped for now.