How to make your Vue apps smaller and faster.

David Mold
6 min readJun 5, 2021


Dynamically load your code at the component level.

Some things are bigger than others.

Have you ever added a dependency to your Vue project and suddenly realized you have blown your budget on bundle size? I sure have. When you test your project in Lighthouse, does it demand you reduce the amount of unused JavaScript? Of course, it is up to you to figure out how to do that, and it is not at all obvious. Many tutorials suggest dynamically loading your routes, and that can help, but it can also slow your app down.

There are two main bundles that your Vue app will load when it starts, and the browser has to parse them before it can start interacting with the user. Assuming you have set up your build with vue cli, those are the app.js and chunk-vendor.js files in your dist folder. You’ll see links to them added near the end of index.html, and if you have filename hashing turned on, it will look something like this:

<script src="/js/chunk-vendors.cb6c71e5.js"></script>
<script src="/js/app.ccdd4101.js"></script>

If either of them are much over 160K or so in size, it is probably worth looking at how you can improve matters. Take a peek in your dist folder and you’ll soon see what you’re facing.

Dynamically loading routes is not the whole answer

When you first start looking for help with this, you will quickly discover one main approach. Assuming you are using Vue Router, adopting dynamic imports to load each page is quick and easy to implement. In your router.js (or ./router/index.js) this just means that, instead of doing this:

import { createRouter, createWebHistory } from 'vue-router'import HomePage from './views/HomePage.vue'
import ContactPage from './views/ContactPage.vue'
const router = createRouter({
history: createWebHistory('/'),
routes: [
{ path: '/', name: 'home', component: HomePage },
{ path: '/contact', name: 'contact', component: ContactPage }

You can simply do this:

import { createRouter, createWebHistory } from 'vue-router'const router = createRouter({
history: createWebHistory('/'),
routes: [
{ path: '/', name: 'home',
component: () => import('./views/HomePage.vue') },
{ path: '/contact', name: 'contact',
component: () => import('./views/ContactPage.vue'}

It’s important to understand exactly what’s going on here. Instead of importing the component at the start, you are asking the router to load it from the import function only when the user requests the page. You could write that function out in a less-condensed form, and actually log when the route gets loaded, which I would recommend you try, for example:

component: () => {
console.log('loading contact page now')
return import('./views/ContactPage.vue')

Then if you watch the console while you visit that page, you will see exactly when the route is loaded. You can even add a webpack special comment to specify which named chunk the route gets added to, for example:

component: () => {
console.log('loading contact page now')
return import(/* webpackChunkName: "contact" */ './views/ContactPage.vue')

Then when you look in your ./dist/js folder and build, you will see the file there, so you can see exactly how large that route is.

Small bundle, slow load

This may well drastically reduce your initial bundle size, but you also need to be careful about speed. Doing this means that if the user hits ‘/contact’ first, they will face a delay while that route loads asynchronously. You really want to keep the most important routes inside your initial bundle, otherwise the browser will waste time at first load scooping up and parsing the separate bundles, and this can be very costly to your load speed. And aren’t all of your routes important?!

So if, like me, you have clients who love to cram their pages with complex components and giant animating graphics, yet you still need a small bundle size, and fast load speed — what do you do?

The answer is to use the same trick you used with the router, but inside your pages instead. You need to treat the different areas of your complex pages as if they are separate routes. This works just as well with Vue 3 and Vue 2.

Detect when components are needed

First you must detect when your component is visible, or just about to become visible, and only load it then. This only works for components that are not “above the fold”, and that is going to vary depending on the layout and the device being used, so you need to be cunning in how you set it up.

It is good practice to make all of your components load as a correctly sized facade immediately on page load, which will reduce your cumulative layout shift (CLS) — something that has become very important to Google, for reasons. This also helps you to determine when components lower down the page need to load.

Typically this means figuring out where your component’s viewport is, which you can do with element.getBoundingClientRect(), listening (passively) for the scroll event to tell you it’s coming into view, and dynamically loading the component at that time.

So create a simple Vue component called ViewDetector.vue like this:

<div class="view-detector">
<div class="facade">
export default {
mounted() {
window.addEventListener('scroll', this.onscroll, { passive:true})
methods: {
onscroll() {
let bb = this.$el.getBoundingClientRect()
let btop = window.innerHeight
if(bb.bottom >= 0 && <= btop){
window.removeEventListener('scroll', this.onscroll)

This component has just one job: it detects when it has been seen, and emits a “seen” event then. The .facade class is the same height as our loaded component will be, to ensure there is no layout shift (or jankiness) when the component loads.

Asynchronously load dynamic components

Back in the template of our main page, we have something like this:

<view-detector v-on:seen="sawitem = true">
<complex-component v-if="sawitem"></complex-component>

Now, when we scroll down the page and reach the view detector, it will fire its seen event. We have an instance variable “sawitem”, and when that is true, our dynamic complex component loads.

So the script section of our main page in Vue 3 looks like this. The script in bold shows how the component gets loaded dynamically, just like the dynamic route loading:

import ViewDetector from './ViewDetector.vue'
import { defineAsyncComponent } from 'vue'
export default {
components: {
ComplexComponent: defineAsyncComponent(
() => import('./ComplexComponent.vue')

data() {
return {
sawitem: false

If you’re using Vue 2, it’s even simpler because you don’t need to define an async component, and all you need is:

ComplexComponent: () => import('./ComplexComponent.vue')

Then, when you load the page and scroll down to where our component should be, it will load just as you arrive.

Why is this good? For two reasons:

  1. The code for our complex component will not be loaded in our app.js, and any dependencies it uses won’t be in our chunk-vendor.js, so our initial load will be smaller.
  2. The code for the visible part of the page will be in those chunks, and will load very fast.

You have to be careful when using this approach that Vue correctly detects when to load the code for dynamic components. It’s not always intuitive, so it’s well worth putting in some logging during development to make sure your dynamic components are loaded when you think they ought to be and not before.

It’s also good to use asynchronous components for any off-screen items in your pages like menus and dialogs — otherwise their code will also find its way into your app.js even though the user may never use or see them.

With this approach, our app will be smaller and load faster, Google will be happy, our client will be happy, and most importantly you will be happy. As long as you understand how this works, Vue gives you all the tools you need to build very small, fast-loading apps, no matter how large and complex the project is.

You can get the source code for the demo project here.