Reducing Bundle Size in Large Vue Apps: A Strategic Guide

Strategic guide to bundle optimization. Learn tree-shaking, dynamic imports, dependency analysis, code splitting, and continuous monitoring.

The Bundle Problem

Vue apps grow organically, and nobody watches the bundle until it hurts. Working on a clinical data platform, I shipped a bundle reduction of 35% using the approach in this guide — the strategy below is the repeatable part.

To make the mechanics concrete, this guide walks through a representative large Vue app. All specific sizes below are an illustrative example, not measurements from one project:

  • Main bundle: 850 KB (gzipped: 280 KB)
  • Vendor bundle: 450 KB (gzipped: 150 KB)
  • Total: 1.2 MB gzipped

At 3G speeds (1 Mbps), that's 10+ seconds just for JavaScript.

Step 1: Analyze the Damage

First, visualize where bytes are going:

bash
npm install -D rollup-plugin-visualizer

# vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default {
  plugins: [vue(), visualizer()]
}

npm run build
# Opens dist/stats.html — interactive bundle visualization

You'll see:

  • What packages are large
  • What's imported but unused
  • Duplicate dependencies at different versions

A typical analysis reveals (illustrative example):

  • lodash-es: 71 KB (for 3 functions actually used)
  • moment: 65 KB (replaced with date-fns)
  • chart.js: 120 KB (only used on one page)
  • Duplicate axios: two versions imported by different packages

Step 2: Tree-Shake Aggressively

Remove Unused Packages

bash
# Find unused dependencies
npm install -g depcheck
depcheck

# Removes 50+ KB of dead code
npm uninstall unused-package

Replace Heavy Packages

typescript
// ❌ lodash-es (71 KB)
import { debounce, throttle, cloneDeep } from 'lodash-es'

// ✅ Use native JavaScript + small libraries
// Debounce (use native setTimeout)
const debounce = (fn: Function, delay: number) => {
  let timeout: NodeJS.Timeout
  return (...args: any[]) => {
    clearTimeout(timeout)
    timeout = setTimeout(() => fn(...args), delay)
  }
}

// Or use a lighter library
import { debounce } from 'radash' // 8 KB vs 71 KB

Dependency Consolidation

typescript
// ❌ Multiple date libraries
import { format } from 'date-fns'
import moment from 'moment' // Someone else's code
import dayjs from 'dayjs' // Yet another

// ✅ Choose one
import { format } from 'date-fns'
// date-fns: 30 KB (tree-shakeable) vs moment: 65 KB (not tree-shakeable)

Build Config for Tree-Shaking

typescript
// vite.config.ts
export default defineConfig({
  build: {
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,
        drop_debugger: true
      }
    },
    rollupOptions: {
      output: {
        // Manual code splitting
        manualChunks: {
          // Separate large dependencies
          'charts': ['chart.js', 'vue-chartjs'],
          'markdown': ['markdown-it', 'highlight.js'],
          'utils': ['lodash', 'date-fns'],
          'vendor': ['vue', 'vue-router', 'pinia']
        }
      }
    }
  }
})

Results for the illustrative example app:

  • Removed unused: -150 KB
  • Replaced heavy packages: -120 KB
  • Tree-shaking optimized: -40 KB

Total: -310 KB (27% reduction) — again, illustrative; the ratio between the three buckets is what generalizes.

Step 3: Smart Code Splitting

Load code only when needed:

typescript
// ❌ All routes imported upfront
import Home from '@/pages/Home.vue'
import Dashboard from '@/pages/Dashboard.vue'
import Settings from '@/pages/Settings.vue'
import Admin from '@/pages/Admin.vue'

const routes = [
  { path: '/', component: Home },
  { path: '/dashboard', component: Dashboard },
  { path: '/settings', component: Settings },
  { path: '/admin', component: Admin }
]

// ✅ Dynamic imports for routes
const routes = [
  { path: '/', component: () => import('@/pages/Home.vue') },
  { path: '/dashboard', component: () => import('@/pages/Dashboard.vue') },
  { path: '/settings', component: () => import('@/pages/Settings.vue') },
  { path: '/admin', component: () => import('@/pages/Admin.vue') }
]

Split Heavy Features

typescript
// ❌ Import editor everywhere (even if not used)
import RichEditor from '@/components/RichEditor.vue'

// ✅ Async only when needed
const RichEditor = defineAsyncComponent(
  () => import('@/components/RichEditor.vue')
)

Webpack Magic Comment

typescript
// Name the chunk for better debugging
const Dashboard = () =>
  import(
    /* webpackChunkName: "dashboard" */
    '@/pages/Dashboard.vue'
  )

Generates: dist/dashboard.abc123.js (clear what it contains)

Results of code splitting (illustrative example):

  • The main bundle shrinks to what the first route actually needs
  • Lazy routes load in small on-demand chunks
  • First page load stops paying for pages the user never visits

Step 4: Monitor Continuously

Prevent regressions:

bash
npm install -D bundlesize
json
// .bundlesize.json
{
  "files": [
    {
      "path": "./dist/index.*.js",
      "maxSize": "250kb"
    },
    {
      "path": "./dist/*.js",
      "maxSize": "100kb"
    }
  ]
}

GitHub Action:

yaml
# .github/workflows/bundle-size.yml
name: Bundle Size Check

on: [pull_request]

jobs:
  bundle:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run build
      - uses: bundlesize/action@v1
        with:
          files: ./dist/**/*.js
          builtFile: dist

Fails PR if bundles exceed limits.

Step 5: Optimize Imports

Named vs Default Exports

typescript
// ❌ Default exports (can't tree-shake)
export default { formatDate: ... }
import * as dateUtils from 'lib'

// ✅ Named exports (tree-shakeable)
export { formatDate, formatTime }
import { formatDate } from 'lib'

Avoid Wildcard Imports

typescript
// ❌ Imports everything
import * as utils from '@/utils'
utils.formatDate()

// ✅ Import only what's needed
import { formatDate } from '@/utils'
formatDate()

Results & Ongoing Monitoring

Before & after for the illustrative example app (your ratios will vary; the direction won't):

MetricBeforeAfterImprovement
Main Bundle850 KB~300 KBlarge
Vendor Bundle450 KB~180 KBlarge
First Paint (3G)~8s~2slarge

In my own production use of this strategy, the verified outcome was a 35% bundle reduction. Beyond raw size:

  • Faster for all users, especially mobile
  • Better SEO (Core Web Vitals improvement)
  • Less battery drain on mobile

Ongoing Strategy

  1. Weekly monitoring — bundlesize checks every PR
  2. Quarterly audits — analyze with visualizer
  3. Department rotation — one engineer owns bundle each month
  4. Team education — awareness of bundle impact
  5. Dependency reviews — approve new packages by size

Pro Tips

  1. Lazy load routes by default — all routes should be dynamic
  2. Split by feature — heavy features in separate chunks
  3. Use es2015 module syntax — enables tree-shaking
  4. Compress with Brotli — 15-20% smaller than gzip
  5. Monitor in production — real user metrics matter most

Bundle size affects every user, every time. It's worth optimizing.