Bundle and Loading Performance: Tree-shaking, Code Splitting and Preloading
Chapter 31: Bundle Size and Loading Performance — Tree-shaking Mechanics, Code Splitting, and Preloading Strategy
Vue 3 deliberately designed all its APIs as named exports rather than properties on a global Vue object. This architectural decision made Tree-shaking possible — an application that only uses
refandcomputedwill have zero bytes fromTransition,KeepAlive, orTeleportin its production bundle. This decision compressed Vue 3's minimum runtime from approximately 33KB (Vue 2) to approximately 16KB (gzip).
The central question of this chapter: How do you systematically optimize a Vue 3 application's performance from both bundle size and loading strategy dimensions, bringing first-screen load time from several seconds down to under 1 second?
After reading this chapter you will understand:
- The static analysis principles behind Tree-shaking, and which code patterns accidentally prevent Tree-shaking from working
- Code splitting strategies at the route, component, and dependency levels, and how to configure
manualChunks - The precise semantic difference between
<link rel="preload">and<link rel="prefetch">, and how Vite automatically injectsmodulepreload
Level 1 · What You Need to Know (1–3 Years Experience)
1.1 Why Bundle Size Is the First Barrier to Performance
Network download time is the largest variable in loading performance. On 4G networks (average 10 Mbps download):
| Bundle size (gzip) | Download time | User perception |
|---|---|---|
| < 50KB | < 40ms | Nearly imperceptible |
| 100KB | ~80ms | Acceptable |
| 300KB | ~240ms | Noticeable wait |
| 1MB | ~800ms | Users start abandoning |
| 2MB | ~1.6s | Serious problem |
First-screen resources (Critical Path) are the most sensitive: when a user opens a page, the browser must download, parse, and execute all first-screen JavaScript before the page becomes interactive (TTI, Time to Interactive). Every additional 100KB of first-screen JS adds approximately 300ms to TTI on a mid-range phone.
1.2 Vue 3's Tree-shaking-Friendly Design
All Vue 3 APIs are named exports:
// Vue 3: import on demand
import { ref, computed, reactive, watch, onMounted } from 'vue';
// Vue 2: used from a global object
import Vue from 'vue';
Vue.observable({ count: 0 }); // this pattern cannot be Tree-shaken
Bundlers (Rollup/Vite) mark any export that isn't referenced during static analysis as "dead code" and remove it from the output.
Real-world impact:
// An app that only uses ref
import { ref } from 'vue';
const count = ref(0);
The bundle only contains the implementation of ref — not reactive, computed, watch, Transition, KeepAlive, or any other unused features.
Vue 3 size reference (after gzip):
- Core reactivity system (ref + reactive + computed): ~8KB
- Runtime DOM renderer: ~4KB
- Compiler (only needed for runtime compilation): ~14KB
- Full runtime (no compiler): ~16KB
- Full build (with compiler): ~30KB
1.3 Vite Code Splitting Strategies
Route-level code splitting (the most important splitting strategy):
// router/index.js
import { createRouter } from 'vue-router';
const routes = [
{
path: '/',
component: () => import('./views/Home.vue'), // lazy loaded
},
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue'), // lazy loaded
},
{
path: '/settings',
component: () => import('./views/Settings.vue'), // lazy loaded
},
];
Vite splits each dynamic import() into an independent chunk. Users only download the code for the routes they actually visit.
Component-level code splitting (large, infrequently-used components):
import { defineAsyncComponent } from 'vue';
// Suitable for: large chart components, rich text editors, PDF viewers, etc.
const RichEditor = defineAsyncComponent({
loader: () => import('./components/RichEditor.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // show loading after 200ms (prevents brief flash)
timeout: 10000, // 10 second timeout
});
Third-party library splitting (manualChunks configuration):
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// Split large third-party libraries into separate chunks
'vendor-echarts': ['echarts'],
'vendor-lodash': ['lodash-es'],
'vendor-vue': ['vue', 'vue-router', 'pinia'],
// The browser can cache these stable libraries
// When the app updates, these chunks don't need to be re-downloaded
}
}
}
}
});
1.4 Preloading Strategy: preload vs prefetch
<link rel="preload">: Declares resources the current page must use; browser downloads them at highest priority:
<!-- Preload the main JS bundle for the current page (highest priority) -->
<link rel="preload" href="/assets/main.js" as="script">
<!-- Preload a Web Font used by the current page -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="prefetch">: Hints to the browser about resources likely needed in the future; downloads during idle time:
<!-- Prefetch a route the user might navigate to next -->
<link rel="prefetch" href="/assets/Dashboard.js">
Precise semantic comparison:
| Feature | preload | prefetch |
|---|---|---|
| Priority | High (current page needs it) | Low (might need in the future) |
| When | Downloads immediately | Downloads during browser idle time |
| Use case | Essential resources for current page | Resources for the next page |
| Caching | Stored in browser cache | Stored in browser cache |
| If unused | Warning (wasted bandwidth) | Silently discarded |
Vite automatically injects modulepreload:
During build, Vite analyzes the module dependency graph and generates <link rel="modulepreload"> tags for the direct dependencies of each entry point:
<!-- Vite auto-generates (index.html) -->
<link rel="modulepreload" crossorigin href="/assets/vue.xxx.js">
<link rel="modulepreload" crossorigin href="/assets/pinia.xxx.js">
<script type="module" src="/assets/main.xxx.js"></script>
1.5 Bundle Analysis Tools
Use rollup-plugin-visualizer to generate a visual bundle size report:
npm install rollup-plugin-visualizer --save-dev
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
filename: './dist/stats.html', // output report file
open: true, // auto-open browser after build
gzipSize: true, // show gzip sizes
brotliSize: true, // show brotli sizes
template: 'treemap', // treemap view is most intuitive
}),
],
});
Common sources of unexpected bundle bloat:
moment.js(~300KB) → replace withday.js(~2KB) or import only needed localeslodash(~72KB) → uselodash-eswith named imports, or replace with native JScore-js(polyfills) → verifytargetsconfig is correct; don't inject polyfills for already-supported browsers- Duplicate icon libraries (e.g. both
@ant-design/iconsandheroicons)
1.6 First-Screen Performance Metrics
Core Web Vitals:
- FCP (First Contentful Paint): When the first content appears, target < 1.8s
- LCP (Largest Contentful Paint): When the largest content element (image or text) renders, target < 2.5s
- TTI (Time to Interactive): When the page is fully interactive, target < 3.8s
Measurement:
import { onCLS, onFID, onLCP, onFCP, onTTFB } from 'web-vitals';
onFCP(metric => console.log('FCP:', metric.value));
onLCP(metric => console.log('LCP:', metric.value));
// Report to analytics platform
onLCP(metric => {
navigator.sendBeacon('/api/vitals', JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
}));
});
Level 2 · How It Actually Works (3–5 Years Experience)
2.1 Tree-shaking's Static Analysis Principle
Rollup/Vite's Tree-shaking is based on the static structure of ES Modules — the dependency relationships of import/export can be determined before the code runs:
Module dependency graph analysis:
entry.js
└── import { ref, computed } from 'vue'
└── vue/index.js (exports ref, reactive, computed, watch, Transition...)
├── ref.js ← referenced, keep
├── computed.js ← referenced, keep
├── reactive.js ← not referenced, mark as dead code
├── watch.js ← not referenced, mark as dead code
└── Transition.js ← not referenced, mark as dead code
Rollup's Mark-Sweep algorithm:
1. Mark phase:
- Start from the entry file, follow import chains
- Mark all referenced exports as "alive"
- Unmarked exports are "dead code"
2. Shake phase:
- Remove all code marked as dead code
- Update module export lists
3. Bundle phase:
- Merge all alive code into output files
What prevents Tree-shaking?
Problem 1: Side effects
If a module executes operations on import (beyond defining functions/values), the bundler must retain it:
// bad-module.js (has side effects)
export const PI = 3.14;
// Module top-level execution (side effect):
console.log('Module loaded'); // this runs on import
window.__myLib = {}; // modifies global state
// Even if only PI is imported, the bundler must keep the entire module
import { PI } from './bad-module.js';
Declare sideEffects in package.json:
// package.json (library configuration)
{
"sideEffects": false
// Tells the bundler: all modules in this package have no side effects
// and can be safely Tree-shaken
}
Vue 3's package.json declares "sideEffects": false — this is one of the key configurations that makes Vue 3 efficiently Tree-shakeable.
Problem 2: Dynamic imports (determined at runtime)
// Prevents Tree-shaking: the imported module is determined at runtime
const featureName = getFeatureName(); // only known at runtime
import(`./${featureName}.js`); // cannot be statically analyzed
Problem 3: CommonJS modules
// CommonJS: cannot be statically analyzed
const lib = require('some-lib');
lib.someFunction(); // bundler doesn't know which parts are used
2.2 Code Splitting Trade-offs and Strategy
More code splitting is not always better — each additional HTTP request has a fixed overhead (approximately 50–100ms round-trip latency). You need to balance "parallel downloads" against "number of requests."
Vite's default splitting strategy:
Entry chunk: main.js → includes Vue + Router + Pinia + App.vue
Dynamic chunks: each () => import() route/component → separate chunk
Vendor chunk: Vite 4+ automatically groups node_modules dependencies
Optimized manualChunks strategy:
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
// Large standalone packages
if (id.includes('echarts')) return 'vendor-echarts';
if (id.includes('three')) return 'vendor-three';
// Medium packages grouped together
if (id.includes('@vueuse')) return 'vendor-vueuse';
if (id.includes('lodash')) return 'vendor-utils';
// Core framework
if (id.includes('vue') || id.includes('pinia')) {
return 'vendor-vue';
}
}
}
}
}
}
});
Best practice: Split ECharts (~1MB uncompressed) into its own chunk. Users only download it when they first visit a page with charts.
2.3 Image Optimization: Lazy Loading and Format Selection
Images are typically the primary contributor to LCP.
IntersectionObserver for image lazy loading:
<script setup>
const imgRef = ref(null);
const isVisible = ref(false);
onMounted(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
isVisible.value = true;
observer.disconnect();
}
},
{ rootMargin: '200px' } // start loading 200px before entering viewport
);
observer.observe(imgRef.value);
});
</script>
<template>
<img
ref="imgRef"
:src="isVisible ? actualSrc : placeholder"
:alt="alt"
/>
</template>
Modern image format size comparison (reference values for same image):
- JPEG: 100KB
- WebP: 60KB (40% savings)
- AVIF: 40KB (60% savings)
Multi-format support with <picture>:
<picture>
<source type="image/avif" srcset="/image.avif">
<source type="image/webp" srcset="/image.webp">
<img src="/image.jpg" alt="description" loading="lazy" decoding="async">
</picture>
The browser selects the best format it supports.
2.4 import.meta.env: Controlling Development vs Production Behavior
Vite provides import.meta.env for distinguishing build environments in code:
// Code that only runs in development (removed in production builds via Tree-shaking)
if (import.meta.env.DEV) {
console.log('Development mode:', state);
installDevtools(app);
}
// Code that only runs in production
if (import.meta.env.PROD) {
initMonitoring();
enableAnalytics();
}
// Environment variables (read from .env files)
const API_URL = import.meta.env.VITE_API_URL;
In Rollup's Tree-shaking, import.meta.env.DEV is replaced with false in production builds, and the entire if (false) { ... } block is removed — it does not appear in the output.
Level 3 · Design Documents and Source Code (Senior Developers)
3.1 Vue 3's Tree-shaking Architectural Design
Vue 3's Tree-shaking friendliness is not accidental — it was achieved through deliberate architectural refactoring. The RFC documents in vuejs/rfcs detail the reasoning:
Vue 2's problem:
// Vue 2: all features mounted on the Vue object
import Vue from 'vue';
Vue.component('MyComponent', ...); // global registration
Vue.directive('my-directive', ...); // global directive
Vue.filter('myFilter', ...); // global filter
// Problem: even if the app doesn't use Vue.filter,
// the bundler cannot remove the filter feature from the Vue object
// because it cannot statically analyze which "Vue.xxx" properties are used
Vue 3's solution:
// Vue 3: all features are named exports
import {
createApp,
ref, reactive, computed,
watch, watchEffect,
onMounted, onUnmounted,
// Import on demand — unused imports don't appear in the bundle
} from 'vue';
// Built-in components are also named exports (optional imports)
import { Transition, TransitionGroup, KeepAlive, Teleport } from 'vue';
Vue 3's critical package.json configuration:
{
"name": "vue",
"sideEffects": false,
"exports": {
".": {
"import": "./dist/vue.esm-bundler.js",
"require": "./dist/vue.cjs.js"
}
}
}
3.2 Vite's Preload Injection Mechanism
Vite automatically injects modulepreload directives into the production output through @rollup/plugin-dynamic-import-vars and a custom plugin.
Vite's module preload injection (from packages/vite/src/node/plugins/importAnalysisBuild.ts, simplified):
function generatePreloadCode(chunk: OutputChunk, modules: string[]) {
return `
const deps = ${JSON.stringify(modules)};
// __vitePreload will at runtime prefetch these modules into the browser cache
return __vitePreload(
() => import(${JSON.stringify(chunk.fileName)}),
true ? deps : void 0
);
`;
}
// The __vitePreload runtime implementation:
// 1. Checks which dependencies are not yet cached
// 2. Creates <link rel="modulepreload"> tags
// 3. Waits for dependency preloading to complete
// 4. Executes the actual import()
This means when user code executes () => import('./views/Dashboard.vue'), Vite's compiled code actually:
- First injects
<link rel="modulepreload" href="/assets/some-dep.js">to preload Dashboard's dependencies - Then executes
import('/assets/Dashboard.js')
This dramatically reduces the waterfall of requests during dynamic route switching.
3.3 HTTP/2's Impact on Code Splitting Strategy
HTTP/2's multiplexing eliminates HTTP/1.1's connection limit, allowing multiple files to be requested in parallel over a single TCP connection. This changes the optimal code splitting strategy:
HTTP/1.1 era:
- Browser limited to 6 parallel connections per domain
- Each chunk requires additional connection overhead
- Recommendation: fewer splits, merge code
HTTP/2 era:
- Theoretically unlimited connections; 100 small files ≈ overhead of 1 large file
- But request headers still have overhead (even when compressed)
- Recommendation: split by cache granularity; stable libraries get their own chunks
Verify that your server supports HTTP/2:
curl -I --http2 https://yourdomain.com
# Response should include HTTP/2 200
Enable HTTP/2 in Nginx:
server {
listen 443 ssl http2; # add http2 parameter
# ...
}
Level 4 · Edge Cases and Traps (For Everyone)
Trap 1: import * as Breaks Tree-shaking
Broken code:
// Wrong: namespace import — bundler can't know which parts are used
import * as VueRouter from 'vue-router';
const { createRouter, useRoute } = VueRouter;
// Output: the entire vue-router is bundled!
Correct code:
// Correct: named imports — bundler can Tree-shake
import { createRouter, useRoute } from 'vue-router';
Edge case: Even with named imports, accessing them through dynamic property lookup prevents Tree-shaking:
import { computed, ref, reactive } from 'vue';
// Wrong: dynamic property access
const apis = { computed, ref, reactive };
const apiName = getApiName(); // only known at runtime
apis[apiName](); // bundler must retain all three
Trap 2: defineAsyncComponent Chunk Names Are Unstable by Default
Problem: Vite names chunks by content hash by default, causing unstable filenames:
// Dynamic import generates a chunk with a hash in the filename:
// views/Dashboard-8f3a2b.js (hash changes with any content change)
const Dashboard = () => import('./views/Dashboard.vue');
When code changes, even if Dashboard.vue itself didn't change, the chunk hash may change (because the dependency graph changed), invalidating the browser cache.
Solution: Use Rollup's magic comments or configure chunkFileNames:
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
chunkFileNames: 'assets/[name]-[hash].js',
// [name] comes from the manualChunks key or the module filename
}
}
}
});
Trap 3: Overusing preload Wastes Bandwidth
Broken code:
<!-- Wrong: preloading chunks for all routes -->
<link rel="preload" href="/assets/Home.js" as="script">
<link rel="preload" href="/assets/Dashboard.js" as="script">
<link rel="preload" href="/assets/Settings.js" as="script">
<link rel="preload" href="/assets/UserProfile.js" as="script">
<!-- User might only visit one route, but all files are downloaded -->
Correct strategy: Only preload resources required by the current page; use prefetch for resources the user might need:
// Dynamically add prefetch in route navigation guard
router.afterEach((to, from) => {
// Based on current route, prefetch the most likely next route
if (to.name === 'home') {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = '/assets/Dashboard.js';
document.head.appendChild(link);
}
});
Trap 4: manualChunks Circular Dependencies Cause Build Failures
Problem scenario:
manualChunks: {
'vendor-a': ['module-a'],
'vendor-b': ['module-b'],
}
// If module-a depends on module-b,
// and module-b is assigned to a different chunk,
// Rollup may error: circular dependencies cannot be handled correctly
Investigation approach:
// Function form of manualChunks is more flexible
manualChunks(id) {
if (id.includes('module-a') || id.includes('module-b')) {
// Put mutually-dependent modules in the same chunk
return 'vendor-ab';
}
}
Trap 5: loading="lazy" on LCP Images Hurts Performance
Broken scenario: Using loading="lazy" on a large above-the-fold image (LCP candidate):
<!-- Wrong: the hero image should NOT be lazy loaded -->
<img
src="/hero-image.jpg"
loading="lazy" <!-- This delays LCP! -->
alt="Product showcase"
>
loading="lazy" defers image loading until the image enters the viewport. For large above-the-fold images, this directly delays LCP, worsening the LCP metric.
Correct strategy: Only lazy load images below the fold:
<!-- Above-the-fold image: actively preload -->
<link rel="preload" as="image" href="/hero-image.jpg">
<img src="/hero-image.jpg" fetchpriority="high" alt="Product showcase">
<!-- Below-the-fold images: lazy load -->
<img src="/below-fold.jpg" loading="lazy" alt="More content">
Chapter Summary
-
Tree-shaking depends on static analysis: Named imports (
import { ref }) can be Tree-shaken; namespace imports (import * as Vue) and CommonJSrequire()cannot. The"sideEffects": falsedeclaration inpackage.jsonis the essential flag that allows a library to be efficiently Tree-shaken. -
Three-layer code splitting strategy: Route-level splitting (dynamic
import()) is mandatory and reduces initial bundle size by 60–80%; component-level splitting (defineAsyncComponent) is for large infrequently-used components;manualChunksgives stable third-party libraries their own chunks, maximizing browser cache utilization. -
Preload for the current page, prefetch for the next page: Overusing preload wastes bandwidth (browsers warn about unused preload resources); Vite's automatically-injected
modulepreloadeliminates waterfall requests from dynamic imports; hero images above the fold must be explicitly preloaded, neverloading="lazy". -
Bundle analysis is an essential engineering practice:
rollup-plugin-visualizer's treemap view visually shows bundle size distribution;moment.js/lodash/core-jsare the most common sources of unexpected bloat — replace them withday.js/lodash-es/precisetargets. -
HTTP/2 changes the optimal splitting granularity: The HTTP/1.1 era favored merging code to reduce request count; the HTTP/2 era favors splitting by cache granularity (stable libraries vs. frequently-changing business code), fully leveraging browser caching and minimizing cache invalidation scope during releases.