Our Vue app was slow. Initial load took 3 seconds, scrolling was janky, and users complained about lag.

I spent a week optimizing. Now it loads in 800ms, scrolling is smooth, and users are happy. Here’s what I did.

Table of Contents

The Problem

Performance metrics:

  • Initial load: 3.2s
  • Bundle size: 2.5MB
  • Time to interactive: 4.1s
  • Scroll FPS: 30-40

Users on slow connections waited 10+ seconds.

Code Splitting

Split bundle by route:

Before:

import UserList from './components/UserList.vue'
import UserDetail from './components/UserDetail.vue'
import Dashboard from './components/Dashboard.vue'

const routes = [
  { path: '/users', component: UserList },
  { path: '/users/:id', component: UserDetail },
  { path: '/dashboard', component: Dashboard }
]

One big bundle: 2.5MB

After:

const routes = [
  {
    path: '/users',
    component: () => import('./components/UserList.vue')
  },
  {
    path: '/users/:id',
    component: () => import('./components/UserDetail.vue')
  },
  {
    path: '/dashboard',
    component: () => import('./components/Dashboard.vue')
  }
]

Multiple small bundles:

  • app.js: 200KB
  • users.js: 150KB
  • dashboard.js: 180KB

Load only what’s needed!

Lazy Loading Components

Load components on demand:

<template>
  <div>
    <button @click="showModal = true">Open Modal</button>
    <modal v-if="showModal" @close="showModal = false"></modal>
  </div>
</template>

<script>
export default {
  components: {
    Modal: () => import('./Modal.vue')
  },
  data() {
    return {
      showModal: false
    }
  }
}
</script>

Modal only loads when button clicked.

Computed Properties Caching

Bad (method, recalculates every time):

<template>
  <div>{{ expensiveCalculation() }}</div>
</template>

<script>
export default {
  methods: {
    expensiveCalculation() {
      return this.items.reduce((sum, item) => sum + item.price, 0)
    }
  }
}
</script>

Good (computed, cached):

<template>
  <div>{{ totalPrice }}</div>
</template>

<script>
export default {
  computed: {
    totalPrice() {
      return this.items.reduce((sum, item) => sum + item.price, 0)
    }
  }
}
</script>

Computed properties cache until dependencies change.

Virtual Scrolling

Rendering 10,000 items:

Before (renders all):

<template>
  <div class="list">
    <div v-for="item in items" :key="item.id" class="item">
      {{ item.name }}
    </div>
  </div>
</template>

10,000 DOM nodes = slow!

After (virtual scrolling):

<template>
  <virtual-list
    :size="50"
    :remain="10"
    :bench="5"
  >
    <div v-for="item in items" :key="item.id" class="item">
      {{ item.name }}
    </div>
  </virtual-list>
</template>

<script>
import VirtualList from 'vue-virtual-scroll-list'

export default {
  components: { VirtualList }
}
</script>

Only renders visible items (~20 DOM nodes).

v-if vs v-show

v-if (removes from DOM):

<modal v-if="showModal"></modal>

Use for rarely toggled elements.

v-show (CSS display):

<sidebar v-show="sidebarOpen"></sidebar>

Use for frequently toggled elements.

Functional Components

Stateless components:

Before:

<template>
  <div class="item">
    <span>{{ item.name }}</span>
    <span>{{ item.price }}</span>
  </div>
</template>

<script>
export default {
  props: ['item']
}
</script>

After (functional):

<template functional>
  <div class="item">
    <span>{{ props.item.name }}</span>
    <span>{{ props.item.price }}</span>
  </div>
</template>

No instance, faster rendering.

Object.freeze for Static Data

Large static data:

export default {
  data() {
    return {
      items: Object.freeze([
        { id: 1, name: 'Item 1' },
        { id: 2, name: 'Item 2' },
        // ... 10,000 items
      ])
    }
  }
}

Vue won’t make it reactive, saves memory.

Debounce Input

Search input:

Before (searches on every keystroke):

<template>
  <input v-model="searchQuery" @input="search">
</template>

<script>
export default {
  methods: {
    search() {
      // API call on every keystroke!
      this.fetchResults(this.searchQuery)
    }
  }
}
</script>

After (debounced):

<template>
  <input v-model="searchQuery" @input="debouncedSearch">
</template>

<script>
import _ from 'lodash'

export default {
  created() {
    this.debouncedSearch = _.debounce(this.search, 300)
  },
  methods: {
    search() {
      this.fetchResults(this.searchQuery)
    }
  }
}
</script>

Waits 300ms after typing stops.

Keep-Alive for Cached Components

Cache component state:

<template>
  <keep-alive>
    <component :is="currentView"></component>
  </keep-alive>
</template>

Component state preserved when switching.

Production Build

Enable production mode:

// webpack.config.js
const webpack = require('webpack')

module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production')
    })
  ]
}

Or use Vue CLI:

npm run build

Removes warnings, enables optimizations.

Bundle Analysis

Analyze bundle size:

npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}

Visualize what’s in your bundle.

Tree Shaking

Import only what you need:

Bad:

import _ from 'lodash'
_.debounce(fn, 300)

Imports entire lodash (70KB).

Good:

import debounce from 'lodash/debounce'
debounce(fn, 300)

Imports only debounce (5KB).

Image Optimization

Lazy load images:

<template>
  <img v-lazy="imageUrl" alt="Product">
</template>

<script>
import VueLazyload from 'vue-lazyload'
Vue.use(VueLazyload)
</script>

Use WebP format:

<picture>
  <source srcset="image.webp" type="image/webp">
  <img src="image.jpg" alt="Fallback">
</picture>

Prefetch/Preload

Prefetch next route:

const routes = [
  {
    path: '/users',
    component: () => import(/* webpackPrefetch: true */ './UserList.vue')
  }
]

Preloads during idle time.

Results

Before:

  • Initial load: 3.2s
  • Bundle size: 2.5MB
  • Time to interactive: 4.1s
  • Scroll FPS: 30-40

After:

  • Initial load: 800ms (75% faster)
  • Bundle size: 200KB initial (92% smaller)
  • Time to interactive: 1.2s (71% faster)
  • Scroll FPS: 60 (smooth)

Lessons Learned

  1. Code splitting - Biggest impact
  2. Lazy loading - Load on demand
  3. Virtual scrolling - For long lists
  4. Computed caching - Free optimization
  5. Measure first - Use profiler

Conclusion

Vue.js performance optimization is about loading less and rendering smarter.

Key takeaways:

  1. Code split by route
  2. Lazy load components
  3. Use computed properties
  4. Virtual scroll long lists
  5. Analyze and optimize bundle

Performance matters. Users notice the difference.