How I Cut My Vue App’s Bundle Size by 60% Without Rewriting a Single Component

Lazy routes, async components, tree-shaking pitfalls, icon library bloat, and the one Vite plugin that pays for itself in 10 minutes — a real optimization audit with before/after numbers you can replicate today.


The app worked. Users could use it. The team was happy with the features.

Then someone opened Lighthouse and the performance score was 34.

The initial JavaScript bundle was 2.1MB. Time to Interactive was 8.4 seconds on a simulated mid-range mobile device. Largest Contentful Paint was 6.2 seconds. Core Web Vitals: all red.

Nobody had done anything wrong, exactly. The code was reasonable. The architecture was standard. The problem was a series of small, common decisions that each added a little to the bundle, with nobody keeping score until the score became impossible to ignore.

After one focused audit day — no rewrites, no architecture changes, no component refactors — the bundle was 840KB. TTI was 2.1 seconds. LCP was 1.8 seconds. Performance score: 89.

This is what changed, in the order we changed it.


Step 0: Measure Before You Touch Anything

The first and most important rule of bundle optimization: never guess where the weight is. Install the bundle visualizer, run a production build, and look at what’s actually in the bundle.

npm install --save-dev rollup-plugin-visualizer
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    vue(),
    visualizer({
      open:     true,       // opens the report in your browser after build
      filename: 'dist/stats.html',
      gzipSize: true,       // show gzip sizes — what the user actually downloads
      brotliSize: true,     // show brotli sizes
    }),
  ],
})
npm run build
# Opens stats.html automatically — a treemap of every module in your bundle

The visualizer shows you the treemap of every module in the bundle with its size. In our case, it immediately revealed three things we hadn’t expected:

  1. lodash was 531KB — we were using 4 functions from it
  2. @mdi/font (Material Design Icons) was 398KB — we used about 30 of its 7000+ icons
  3. Every route was bundled into the main chunk — nothing was lazy-loaded

These three issues accounted for nearly 900KB of our 2.1MB bundle.


Fix 1: Lazy-Load Your Routes (Free 40% Bundle Reduction)

This was the biggest single win and required the least code change. Every route component was eagerly imported — loaded immediately when the app boots, regardless of whether the user ever visits that route.

// ✗ Before — all routes in the main bundle
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard     from '@/pages/Dashboard.vue'
import Orders        from '@/pages/Orders.vue'
import Customers     from '@/pages/Customers.vue'
import Reports       from '@/pages/Reports.vue'
import Settings      from '@/pages/Settings.vue'
import AdminPanel    from '@/pages/AdminPanel.vue'

const routes = [
  { path: '/',          component: Dashboard },
  { path: '/orders',    component: Orders },
  { path: '/customers', component: Customers },
  { path: '/reports',   component: Reports },
  { path: '/settings',  component: Settings },
  { path: '/admin',     component: AdminPanel },
]
// ✓ After — each route is a separate chunk, loaded on demand
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path:      '/',
    component: () => import('@/pages/Dashboard.vue'),
  },
  {
    path:      '/orders',
    component: () => import('@/pages/Orders.vue'),
  },
  {
    path:      '/customers',
    component: () => import('@/pages/Customers.vue'),
  },
  {
    path:      '/reports',
    component: () => import('@/pages/Reports.vue'),
    // Only load when the user navigates here
  },
  {
    path:      '/settings',
    component: () => import('@/pages/Settings.vue'),
  },
  {
    path:      '/admin',
    component: () => import('@/pages/AdminPanel.vue'),
    // Admin section — most users never see this
  },
]

Vite automatically splits each dynamically imported module into its own chunk. The router handles the loading state transparently — the component resolves before the navigation completes.

Result: Main bundle dropped from 2.1MB to 1.28MB. The Dashboard chunk (the initial page) was 340KB. Users loading the app for the first time downloaded 340KB instead of 2.1MB.

Grouping Related Routes Into a Named Chunk

For related routes that should load together (e.g., all admin pages), use the magic comment syntax to name the chunk:

const routes = [
  {
    path:      '/admin/users',
    component: () => import(/* webpackChunkName: "admin" */ '@/pages/admin/Users.vue'),
  },
  {
    path:      '/admin/settings',
    component: () => import(/* webpackChunkName: "admin" */ '@/pages/admin/Settings.vue'),
  },
  // Both load from the same "admin" chunk — one network request for both
]

Fix 2: Replace lodash with Native Methods or lodash-es

Lodash’s default import pulls the entire library — 531KB — even if you use four functions.

// ✗ This imports ALL of lodash — 531KB gzipped ~72KB but still unnecessary
import _ from 'lodash'
const result = _.groupBy(orders, 'status')
const sorted = _.sortBy(users,  ['lastName', 'firstName'])
const flat   = _.flatten(nested)
const unique = _.uniqBy(items, 'id')

Option A: Use native JavaScript (zero bundle impact)

Most lodash functions have native equivalents in modern JavaScript:

// groupBy — native (ES2024 Object.groupBy is well-supported)
const grouped = Object.groupBy(orders, order => order.status)

// sortBy — native sort with a comparison function
const sorted = [...users].sort((a, b) =>
  a.lastName.localeCompare(b.lastName) || a.firstName.localeCompare(b.firstName)
)

// flatten — native flat()
const flat = nested.flat()

// uniqBy — native with Map
const unique = [...new Map(items.map(item => [item.id, item])).values()]

Option B: Use lodash-es with named imports (tree-shakable)

If you need lodash’s exact behaviour or its more complex utilities:

npm install lodash-es
npm install --save-dev @types/lodash-es
// ✓ Named imports from lodash-es — only the four functions are bundled
import { groupBy, sortBy, flatten, uniqBy } from 'lodash-es'

const grouped = groupBy(orders, 'status')
const sorted  = sortBy(users, ['lastName', 'firstName'])

Result of switching to native methods: 531KB removed from the bundle. Entire fix: 20 minutes of search-and-replace.


Fix 3: Stop Importing Your Entire Icon Library

@mdi/font at 398KB was our second largest problem. It was being imported globally in main.ts:

// ✗ main.ts — imports all 7000+ Material Design Icons as a web font
import '@mdi/font/css/materialdesignicons.css'

This single import pulled in a CSS file and an icon font covering thousands of icons, of which the app used approximately 30.

Solution A: Switch to an SVG Icon Library with Tree-Shaking

unplugin-icons auto-imports only the icons you actually use:

npm install --save-dev unplugin-icons @iconify/json
// vite.config.ts
import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver'
import Components from 'unplugin-vue-components/vite'

export default defineConfig({
  plugins: [
    vue(),
    Icons({ compiler: 'vue3' }),
    Components({
      resolvers: [
        IconsResolver({
          prefix: 'Icon',         // <IconMdiHome /> auto-imports the home icon
          enabledCollections: ['mdi', 'heroicons'],
        }),
      ],
    }),
  ],
})
<!-- Usage — no import statement needed, icons are auto-imported -->
<template>
  <IconMdiHome />
  <IconMdiAccount />
  <IconHeroiconsOutlineSearch />
</template>

Only the icons you actually use in your templates are bundled — nothing else.

Solution B: Use Iconify Inline with On-Demand Loading

npm install @iconify/vue
<script setup lang="ts">
import { Icon } from '@iconify/vue'
</script>

<template>
  <!-- Icons loaded on-demand from the Iconify CDN or local data -->
  <Icon icon="mdi:home" />
  <Icon icon="mdi:account" />
</template>

Result: 398KB icon font removed. Replaced with ~8KB of SVG data for the 30 icons actually used. The per-icon bundle footprint with unplugin-icons is roughly 250 bytes per icon.


Fix 4: Async Heavy Components with defineAsyncComponent

Some components are heavy but not needed immediately — chart libraries, rich text editors, PDF viewers, date pickers with large locale data. Using defineAsyncComponent defers loading until the component is first rendered.

// ✗ Before — heavy chart library loads at startup
import { HeavyDataChart } from '@/components/charts/HeavyDataChart.vue'
// chart.js + custom component = ~200KB, loaded before any chart is shown
// ✓ After — chart loads only when first rendered
import { defineAsyncComponent } from 'vue'

const HeavyDataChart = defineAsyncComponent(() =>
  import('@/components/charts/HeavyDataChart.vue')
)

// With loading and error states:
const HeavyDataChart = defineAsyncComponent({
  loader:           () => import('@/components/charts/HeavyDataChart.vue'),
  loadingComponent: ChartSkeleton,    // shown while loading
  errorComponent:   ChartError,       // shown if loading fails
  delay:            200,              // show loadingComponent after 200ms
  timeout:          10000,            // timeout after 10s
})
<!-- Used in template exactly the same way — Suspense handles the async -->
<template>
  <Suspense>
    <HeavyDataChart :data="chartData" />
    <template #fallback>
      <ChartSkeleton />
    </template>
  </Suspense>
</template>

What We Async-Loaded and the Size Impact

// These components were deferred — none needed for initial render
const RichTextEditor  = defineAsyncComponent(() => import('@/components/RichTextEditor.vue'))
// tiptap + extensions = ~180KB → now loads only when user opens the editor

const PDFViewer       = defineAsyncComponent(() => import('@/components/PDFViewer.vue'))
// pdfjs-dist = ~310KB → now loads only when user opens a PDF

const AdvancedFilters = defineAsyncComponent(() => import('@/components/AdvancedFilters.vue'))
// date-fns locales = ~85KB → now loads only when filters panel opens

const DataExporter    = defineAsyncComponent(() => import('@/components/DataExporter.vue'))
// xlsx = ~220KB → now loads only when user exports data

Result: ~795KB removed from the initial bundle across these four components.


Fix 5: The Tree-Shaking Pitfall — Named vs Default Imports

Tree-shaking removes unused code — but only when the bundler can statically analyse what’s used. Some import patterns defeat tree-shaking entirely.

The Barrel File Problem

Many projects use barrel files (index.ts) for clean imports. These can silently break tree-shaking:

// utils/index.ts — barrel file
export { formatDate }   from './formatDate'
export { formatCurrency } from './formatCurrency'
export { formatPhone }  from './formatPhone'
export { debounce }     from './debounce'
export { deepClone }    from './deepClone'
// ... 20 more exports
// Component — only uses formatDate
import { formatDate } from '@/utils'
// ✗ Some bundlers will include the entire utils barrel
// even though only formatDate is used

The fix: import directly from the source file, or enable "sideEffects": false in package.json:

// ✓ Direct import — tree-shaker can see exactly what's used
import { formatDate } from '@/utils/formatDate'
// package.json — tells bundlers this package has no side effects
{
  "sideEffects": false
}

Or if you have CSS side effects:

{
  "sideEffects": ["*.css", "*.scss"]
}

The CommonJS Import Trap

CJS imports (require-style) cannot be tree-shaken. If a library only ships CJS, the entire library is included regardless of what you use.

// ✗ CJS — entire moment.js included (~290KB)
const moment = require('moment')

// ✓ ES Module alternative — date-fns with named imports (~20KB for what you use)
import { format, addDays, differenceInDays } from 'date-fns'

Check if a library ships ES modules: look for a module or exports field pointing to .mjs files in package.json. If a library only has a main field, it’s CJS-only — consider an ESM alternative.


Fix 6: Vite Build Configuration — The Settings That Matter

// vite.config.ts — production build optimizations
import { defineConfig, splitVendorChunkPlugin } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue(),
    splitVendorChunkPlugin(),  // splits vendor code into a separate chunk for better caching
  ],

  build: {
    // Rollup options for chunk splitting
    rollupOptions: {
      output: {
        // Manual chunk splitting — group related dependencies
        manualChunks: {
          // Vue ecosystem — changes rarely, cache for a long time
          'vue-vendor':    ['vue', 'vue-router', 'pinia'],

          // Chart library — only loaded on dashboard pages
          'chart-vendor':  ['chart.js', 'vue-chartjs'],

          // Form validation — only loaded on form pages
          'form-vendor':   ['vee-validate', 'yup'],
        },
      },
    },

    // Enable Brotli/Gzip compression reporting
    reportCompressedSize: true,

    // Warn when chunks exceed this size (in KB)
    chunkSizeWarningLimit: 500,

    // Target modern browsers — smaller output than legacy targets
    target: 'es2020',

    // Minification
    minify: 'esbuild',     // faster than terser, similar output size
  },

  // CSS code splitting — each chunk only loads the CSS it needs
  css: {
    devSourcemap: false,  // disable in production for smaller maps
  },
})

Enabling Brotli Compression

The vite-plugin-compression plugin generates pre-compressed .br and .gz files that your server can serve directly:

npm install --save-dev vite-plugin-compression
import viteCompression from 'vite-plugin-compression'

export default defineConfig({
  plugins: [
    vue(),
    viteCompression({
      algorithm: 'brotliCompress',  // best compression ratio
      ext: '.br',
    }),
    viteCompression({
      algorithm: 'gzip',            // fallback for servers that don't support brotli
      ext: '.gz',
    }),
  ],
})

Then configure Nginx to serve pre-compressed files:

# nginx.conf
gzip_static on;    # serves .gz files if available
brotli_static on;  # serves .br files if available (requires ngx_brotli module)

Result: Our 840KB bundle compressed to 210KB with Brotli — what users actually download.


Fix 7: Audit Your UI Component Library

UI libraries are often the hidden weight. The import pattern matters enormously:

// ✗ Imports the ENTIRE component library — even components you never use
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

app.use(ElementPlus)
// ~600KB+

// ✓ Auto-imports only what you use — via unplugin-vue-components
import { defineConfig } from 'vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    vue(),
    Components({
      resolvers: [ElementPlusResolver()],
      // Automatically imports and registers components as you use them
      // <el-button> is auto-imported — you never write the import statement
    }),
  ],
})

The same pattern applies to Vuetify, Naive UI, Quasar, and most major Vue UI libraries. Check if your library has an auto-import resolver — if it does, use it.


Fix 8: Replace Moment.js with date-fns or Temporal

Moment.js is 290KB and not tree-shakable. It cannot be made smaller regardless of how many of its features you use.

// ✗ Moment.js — 290KB, no tree-shaking
import moment from 'moment'
const formatted = moment(date).format('MMMM Do YYYY')
const diff      = moment(end).diff(start, 'days')
// ✓ date-fns — tree-shakable, only pay for what you use
import { format, differenceInDays } from 'date-fns'
const formatted = format(date, 'MMMM do yyyy')   // ~2KB for format function
const diff      = differenceInDays(end, start)    // ~1KB
// ✓ Temporal API (2026: now well-supported natively)
// Zero bundle size — built into the browser
const dt        = Temporal.PlainDate.from(date)
const formatted = dt.toLocaleString('en-US', { dateStyle: 'long' })

Result: Replacing moment.js with date-fns named imports saved 285KB (290KB moment vs ~5KB of date-fns functions we used).


The Complete Before/After Audit

OptimizationBeforeAfterSaved
Route lazy loadingAll in main bundlePer-route chunks760KB
lodash → native/lodash-es531KB~0KB531KB
@mdi/font → unplugin-icons398KB~8KB390KB
defineAsyncComponent (4 components)All eagerLoad on demand795KB
moment.js → date-fns290KB~5KB285KB
Brotli compression840KB transfer210KB transfer630KB

Initial bundle: 2.1MB (1.2MB gzipped) After optimization: 840KB (210KB Brotli) Reduction: 60% by size, 82% by transfer size

Lighthouse scores:

  • Performance: 34 → 89
  • Time to Interactive: 8.4s → 2.1s
  • Largest Contentful Paint: 6.2s → 1.8s

The Quick-Win Checklist

Run through this on any Vue app to find the easy wins:

Immediate wins (< 30 minutes each):
✓ Install rollup-plugin-visualizer and look at what's actually in the bundle
✓ Convert all route imports to dynamic imports ()
✓ Check if lodash is imported — switch to named lodash-es imports or native
✓ Check if moment.js is imported — switch to date-fns
✓ Verify UI component library uses auto-import (not full library import)
✓ Add vite-plugin-compression for Brotli/gzip output

Moderate wins (1-2 hours each):
✓ Identify heavy components (editors, charts, PDF viewers, exporters)
✓ Wrap them with defineAsyncComponent
✓ Check barrel file imports — switch to direct source imports where needed
✓ Audit for CJS-only libraries — find ESM alternatives
✓ Configure manualChunks for stable vendor grouping

Measurement:
✓ Check bundle with gzipSize and brotliSize in visualizer (not raw size)
✓ Use Lighthouse with "Mobile" and "Simulated Mid-range Device" throttling
✓ Measure Time to Interactive and LCP, not just bundle size numbers
✓ Test on a real device or browser-throttled to 4G — the numbers tell a different story

Final Thoughts

The 60% bundle reduction didn’t require rewriting a single component, changing the architecture, or switching frameworks. It required knowing where to look, and knowing which patterns work against the bundler’s ability to optimise.

The single most impactful change — lazy-loading routes — required changing import X from to () => import( on six lines. The second most impactful — replacing lodash and moment.js — was search-and-replace. The entire audit took one day.

Start with the visualizer. It tells you where the weight is. Once you know that, the fixes are almost always straightforward. The bundle is rarely large because of sophisticated architectural problems — it’s usually large because of a few libraries that nobody questioned and a few import patterns that defeat tree-shaking.

Measure first. The numbers don’t lie, and they’ll tell you exactly where to spend the day.

Leave a Reply

Your email address will not be published. Required fields are marked *