Chapter 31

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 ref and computed will have zero bytes from Transition, KeepAlive, or Teleport in 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:


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):

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:

1.6 First-Screen Performance Metrics

Core Web Vitals:

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):

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:

  1. First injects <link rel="modulepreload" href="/assets/some-dep.js"> to preload Dashboard's dependencies
  2. 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:

HTTP/2 era:

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

  1. Tree-shaking depends on static analysis: Named imports (import { ref }) can be Tree-shaken; namespace imports (import * as Vue) and CommonJS require() cannot. The "sideEffects": false declaration in package.json is the essential flag that allows a library to be efficiently Tree-shaken.

  2. 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; manualChunks gives stable third-party libraries their own chunks, maximizing browser cache utilization.

  3. Preload for the current page, prefetch for the next page: Overusing preload wastes bandwidth (browsers warn about unused preload resources); Vite's automatically-injected modulepreload eliminates waterfall requests from dynamic imports; hero images above the fold must be explicitly preloaded, never loading="lazy".

  4. Bundle analysis is an essential engineering practice: rollup-plugin-visualizer's treemap view visually shows bundle size distribution; moment.js/lodash/core-js are the most common sources of unexpected bloat โ€” replace them with day.js/lodash-es/precise targets.

  5. 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.

Rate this chapter
4.6  / 5  (3 ratings)

๐Ÿ’ฌ Comments