How to drastically reduce your bundle size and load time in Vue.js

Karlo Majer
Dec 18, 2020 · 11 min read

Are you satisfied with the time it takes to display your app to the user? Well, if you are reading this, you most likely aren’t. Web performance is a commonly discussed topic and yet a lot of developers don’t bother with improving the speed of their apps. Maybe it’s because management thinks it’s a waste of time, or perhaps developers think it’s too much of a hassle. Neither of these reasons are valid.

Did you know that, according to a research by Google, 53% of the mobile users leave a site if it takes more than 3 seconds to load? On top of that, more than half of the pages tested have weighed more than 2MB, yikes.

Your app performance directly affects its search ranking and conversion rates. Pinterest reduced their initial wait time by 40%, which resulted in a 15% increase in search engine traffic and sign-ups. Walmart had similar results, for every second of reduction in load time their conversions increased by 2%.

So, is it a waste of time to spend a couple of days optimizing your app in order to increase conversion rates, user retention and user experience? I don’t think so.

When I first tested our job matchmaking application swip.work with Lighthouse, it was bad, really bad. Our performance score was only 34 and it took 7 seconds before users could see the welcome page of our app. Since our app is primarily targeted for mobile audience, these tests were done with Simulated Throttling enabled and device set to Mobile in Lighthouse.

Image for post
Image for post

Today, the swip.work app has a performance rating of 95 with the same Lighthouse settings. Below is a list of things that I’ve done to improve performance. The first three and the last tip are general performance tips and aren’t particularly related to Vue.js.

Image compression

Image compression is a must, simply because it takes very little effort yet the results are pretty noticeable. There are plenty of image compression tools out there, so pick whichever you like. I used compressor.io, which managed to reduce the size of our images by 60% without compromising quality. Be careful not to go too heavy with compression as it can make images pixelated and introduce color banding. If you have a lot of large images in your app, I highly recommend you to check out HTML Responsive Images.

Applications that allow users to upload images should almost always compress them before uploading. Users often upload images taken directly from their phone camera and these can be well over a megabyte. Since this was the case with Swip, we used a third-party library to handle the compression.

Performance rating: 46
First Contentful Paint: 6.1s (0.9s reduction)

Analyze the cost of your dependencies

Having too many costly dependencies can really hurt your app’s performance. You need to be aware of how big your dependencies are, and seek alternatives if they are too bloated. Luckily, there’s a tool that can help us! Bundlephobia allows you to look up npm packages and find their minified size. You can also upload your package.json file and it will show you a size breakdown of the dependencies used in your app.

After running the scan, we found quite a few dependencies that were unnecessarily large, for example, a carousel library we used was over 100kb. A quick Google search led us to a 10kb alternative.

There might be times where a smaller library simply doesn’t exist. If that’s the case, you can optimize the existing libraries.

Performance rating: 60
First Contentful Paint: 4.3s (1.8s reduction)

Enable code minification

Minifying your code is fairly simple if you are using webpack 4, you just need to set the mode flag to production inside your webpack config. The production mode uses UglifyJS to minify your code but it also does some other optimizations like removing development-only code in libraries. Another thing you should do is set optimization.nodeEnv: 'production' which further reduces the bundle size as it removes checks and warnings regarding development mode.

Here’s what your webpack config looks like after enabling production mode:

module.exports = {
mode: 'production',
optimization: {
nodeEnv: 'production',
minimize: true
}
};

Webpack 3 requires you to use the DefinePlugin and UglifyJS directly:

const webpack = require('webpack');

You should also set NODE_ENV=production in your production .env file because libraries check the NODE_ENV variable to determine how they should run, which affects performance.

Since we are using Tailwind, our CSS file contains a lot of classes. By using cssnano, we can cut the size of our stylesheet in half. First you need to install cssnano, along with PostCSS and PostCSS-Loader. We are also using MiniCssExtractPlugin to generate a separate CSS file for each JS file and load it on demand.

npm install -D cssnano postcss postcss-loader css-loader sass-loader mini-css-extract-plugin

Next up, add cssnano to your postcss.config.js. If you don’t have the config file, just create it.

module.exports = {
plugins: [
require('cssnano')({
preset: 'default'
})
]
};

Lastly, configure your production webpack config:

const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = {
mode: "production",
module: {
rules: [
{
test: /\.s|css$/,
use: [
{ loader: MiniCssExtractPlugin.loader },
"css-loader",
"postcss-loader",
"sass-loader"
]
}
]
},
plugins: [
new MiniCssExtractPlugin({ filename: "[name].[hash].css" })
]
};

We only experienced a 0.1s reduction because most of these changes were already implemented, except CSS minification which reduced the size of our Tailwind CSS file from 12kb to 6kb.

Performance rating: 62
First Contentful Paint: 4.2s (0.1s reduction)

Lazy loading components

Lazy loading can significantly reduce your bundle size. Everything that is not required on the initial render should be lazy loaded. One common example is a component that’s conditionally hidden with the v-if directive, so something like a sidebar or a modal window. If you want to lazy load a component, do not use the v-show directive on it, you have to use v-if. The reason for that is because v-show always renders the component in the DOM, it just hides it if set to false, while v-if only renders the component if it’s set to true, which is when your component would be downloaded. Here’s how it works:

<script>
const NotificationsModal = () => import("@/components/modals/NotificationsModal");

export default {
name: "UserHeader",
components: {
NotificationsModal
}
};
</script>

This however impacts the user experience. While it’s true that we reduced our bundle size, our users still need to download the lazy loaded components eventually. In the above example, our NotificationsModal component would be downloaded when users trigger its render (e.g. click on the notification bell). This increases the loading time of our notification modal since the component first needs to finish downloading before it can be rendered.

This problem can easily be solved with prefetching. In simple terms, it means that we can download resources in advance before the browser requests them. You don’t have to worry about prefetching affecting your page load speed, as it only starts after the browser has finished the initial load.

Webpack has something called magic comments which can be used to affect the build process. In order to prefetch your components, you just need to add the /* webpackPrefetch: true */ magic comment inside your import statement. Pretty neat, huh?

const NotificationsModal = () => import(/* webpackPrefetch: true */ "@/components/modals/NotificationsModal");

Lazy loaded resources are prefetched by default if you are using Vue CLI 3!

Performance rating: 66
First Contentful Paint: 3.8s (0.4s reduction)

Lazy loading routes

If your application has a lot of routes and you are loading all of them initially, it can ruin your performance by quite a bit. When it comes to lazy loading routes, everything is exactly the same as with components:

import Welcome from "@/pages/Welcome";
const Login = () => import(/* webpackPrefetch: true */ "@/pages/Login");
const Register = () => import(/* webpackPrefetch: true */ "@/pages/Register");
const UserHome = () => import(/* webpackPrefetch: true */ "@/pages/User/Home");
const CompanyHome = () => import(/* webpackPrefetch: true */ "@/pages/Company/Home");

None of the above routes except the first one will be included in the initial bundle. The problem is that if we have a lot of routes, it’s not smart to just leave it like this with only the prefetch magic comment because every prefetch creates a new request to the server. The last thing you want to do is bombard your server with 100+ requests on an initial load. It’s possible to reduce the number of requests by grouping multiple components into a single chunk with the /* webpackChunkName: “chunk-name” */ magic comment:

const CompanyJobs = () => import(/* webpackPrefetch: true */ /* webpackChunkName: "company-side" */ "@/pages/Company/CompanyJobs");
const CompanyConnections = () => import(/* webpackPrefetch: true */ /* webpackChunkName: "company-side" */ "@/pages/Company/CompanyConnections");
const CompanyMessages = () => import(/* webpackPrefetch: true */ /* webpackChunkName: "company-side" */ "@/pages/Company/CompanyMessages");
const CompanySettings = () => import(/* webpackPrefetch: true */ /* webpackChunkName: "company-side" */ "@/pages/Company/CompanySettings");

Lazy loading routes has significantly increased our app load speed since we have a lot of routes in our app.

Performance rating: 77
First Contentful Paint: 2.9s (0.9s reduction)

Lazy loading Vuex modules

All of your Vuex modules are most likely static modules, which means that they are declared during the initialization of your store. All of those static modules are imported and parsed during your initial load. That will likely lower your page load speed and increase the TTI (Time to Interactive).

Dynamic modules are the ones that are registered after creating your Vuex store. By lazy loading dynamic modules, we can reduce our bundle size by quite a bit, depending on how much code we have in those modules. Inside your store.js, remove every module that you don’t need on your initial page load. Do you need a messaging module on your welcome page? Most likely not, remove it. Now you just need to think about where you will load your dynamic modules.

We have a separate layout for our user and company side, so I decided to register the modules in those layout components because I can exclude company-only modules from our user layout and vice-versa. To register modules dynamically, first import them and then register them via $store.registerModule function inside the created() lifecycle method. You should also unregister these modules in the beforeDestroy() lifecycle method if you don’t plan to use them outside that component. Here’s what it looks like:

export default {
name: "CompanyLayout",
created() {
this.$store.registerModule("notifications", notifications);
this.$store.registerModule("jobs", jobs);
this.$store.registerModule("connections", connections);
},
beforeDestroy() {
this.$store.unregisterModule("notifications", notifications);
this.$store.unregisterModule("jobs", jobs);
this.$store.unregisterModule("connections", connections);
},
};

You can combine the above with dynamic imports if you need to load a module only after some user interaction.

Performance rating: 81
First Contentful Paint: 2.6s (0.3s reduction)

Lazy loading plugins and global components

If you have any libraries that are only needed on specific routes, do not include them in the main bundle. Your main bundle should be as small as possible. In our app, we have a config file that’s imported in our main.js to initialize all of the plugins and global components that we need. Removing the non-critical plugins and components resulted in a big file size reduction of our main bundle. All I had to do was move the non-critical stuff to our user and company layouts, like in this example:

export default {
name: "CompanyLayout",
created() {
Vue.use(VueFuse);
Vue.use(VueCarousel);
Vue.component("VueShowdown", VueShowdown);
}
};

As the number of plugins in an application grows, it might not be a good idea to just throw everything in the layout components as their loading time will start to suffer heavily. Let’s say that we want to import and use VueCarousel plugin only on our jobs page and our connections page. Instead of throwing it in the layout component, we can import our carousel on both the jobs and connections components. That way, the import will only happen if a user visits one of those 2 pages. However, by doing this, users end up downloading the library twice. First when they visit the jobs page, and then again when they visit the connections page. To fix this, all you need to do is add the following in your production webpack config:

optimization: {
splitChunks: {
chunks: 'all'
}
}

This allows webpack to automatically group shared dependencies into a single bundle, so that they only have to be downloaded once and can be reused.

Performance rating: 90
First Contentful Paint: 2.1s (0.5s reduction)

Optimizing Google Fonts

If you are using a font from Google Fonts and you just paste the import tag from their website, chances are that it can cause a 300–1000ms increase in load time due to it being a render blocking resource. A font import usually looks like this:

<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">

When you are loading a font from Google, you are actually making two requests. First, you are making a request to fonts.googleapis.com to get the CSS file. The CSS contains @font-face rules with src descriptors, which then download the font from fonts.gstatic.com (hence why Google includes a preconnect link to that address). Try visiting the second link in the above example. This is the first request that you are doing to get your font. Now you just have to copy every @font-face that you need and put them inside a <style> tag in the head of your index.html file:

<style>
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v2/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v2/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7.woff2)
format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
</style>

I only copied the latin unicode block, but if you need the support for others, then copy them as well. You have to do this for each font-weight that you imported.

Don’t forget to remove the stylesheet link from your html, now you should only have:

<link rel="preconnect" href="https://fonts.gstatic.com">

By doing this, we are making one request less and on top of that, our inline CSS is smaller than the stylesheet we previously had to download (given that you didn’t import all of the unicode blocks).

Performance rating: 95
First Contentful Paint: 1.5s (0.6s reduction)

Conclusion

In the unoptimized version, users had to download a 500kb JS bundle before they could see the app. With the above tips, the initial bundle size is reduced to just 70kb. All of the tests were done on a mid-range laptop with the distance between the server and the testing location being 900km. This is what the performance looks like for our mobile users after optimizations:

Image for post
Image for post

I hope that this convinced you about the importance of web optimization and that it’s not as difficult as it seems. These tips will be more than enough to improve your app performance in most cases, however if you are using heavy libraries like Lodash, consider library optimization.

If this got you thinking, let us know.

Building a product or having an idea? Maybe we can help.

Let’s chat!
https://shardlabs.io/.

Shard Labs

Solving real-world problems using blockchain technology

Karlo Majer

Written by

Software Developer at Shard Labs

Shard Labs

Straightforward solutions to complex business challenges. We offer full-cycle services that cover every aspect of software engineering. Our team provides business solutions based on Blockchain technology and develops custom software tailored for organization or business needs.

Karlo Majer

Written by

Software Developer at Shard Labs

Shard Labs

Straightforward solutions to complex business challenges. We offer full-cycle services that cover every aspect of software engineering. Our team provides business solutions based on Blockchain technology and develops custom software tailored for organization or business needs.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store