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:
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 withdate-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
# Find unused dependencies
npm install -g depcheck
depcheck
# Removes 50+ KB of dead code
npm uninstall unused-package
Replace Heavy Packages
// ❌ 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
// ❌ 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
// 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:
// ❌ 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
// ❌ 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
// 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:
npm install -D bundlesize
// .bundlesize.json
{
"files": [
{
"path": "./dist/index.*.js",
"maxSize": "250kb"
},
{
"path": "./dist/*.js",
"maxSize": "100kb"
}
]
}
GitHub Action:
# .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
// ❌ 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
// ❌ 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):
| Metric | Before | After | Improvement |
|---|---|---|---|
| Main Bundle | 850 KB | ~300 KB | large |
| Vendor Bundle | 450 KB | ~180 KB | large |
| First Paint (3G) | ~8s | ~2s | large |
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
- Weekly monitoring — bundlesize checks every PR
- Quarterly audits — analyze with visualizer
- Department rotation — one engineer owns bundle each month
- Team education — awareness of bundle impact
- Dependency reviews — approve new packages by size
Pro Tips
- Lazy load routes by default — all routes should be dynamic
- Split by feature — heavy features in separate chunks
- Use es2015 module syntax — enables tree-shaking
- Compress with Brotli — 15-20% smaller than gzip
- Monitor in production — real user metrics matter most
Bundle size affects every user, every time. It's worth optimizing.