Bundle Size

Read the sizes vite build prints, understand what tree shaking can and cannot drop, and recognise the imports that defeat it.

Syntaxconst { renderChart } = await import('./heavyChart')

Bundle size is the cost a user pays before anything appears on screen. It is also the easiest number to read: vite build prints every file it wrote with its raw size and its gzip size, and the gzip column is what travels over the network. A chunk larger than build.chunkSizeWarningLimit, 500 kB by default, gets a warning in that report.

Tree shaking

Tree shaking drops exports that nobody imports. It needs ES modules and a package that is honest about side effects. Vue itself is built for it: if you never use <Transition>, it is not in the output, and compiling templates at build time instead of shipping the runtime compiler saves about 14 kB min+gzip.

The imports that defeat it

Three patterns keep whole libraries in the bundle. A CommonJS build cannot be analysed statically — lodash ships one, lodash-es does not. A default or namespace import (import * as lib) pulls everything the library exports. And a barrel file that re-exports a hundred modules can drag all of them in for one named import. Check what a dependency costs before you add it.

Splitting the rest

What you cannot delete, you can defer. import('./heavy') returns a promise and gives that module its own chunk, fetched the first time the call runs, so a chart library used on one page stops being part of the first download.

Example

Example · vue
<script setup lang="ts">
import { debounce } from 'lodash-es'
import { onUnmounted, ref } from 'vue'

const emit = defineEmits<{
  search: [term: string]
}>()

const term = ref('')

const emitSearch = debounce((value: string) => emit('search', value), 300)

function onInput(): void {
  emitSearch(term.value)
}

onUnmounted(() => {
  emitSearch.cancel()
})
</script>

<template>
  <form @submit.prevent>
    <label for="report-search">Search reports</label>
    <input
      id="report-search"
      v-model="term"
      type="search"
      @input="onInput"
    />
  </form>
</template>

<style scoped>
form {
  display: flex;
  gap: 0.5rem;
}
</style>

When to use it

  • A first load that ships a date library, a chart library and an icon set the landing page never uses
  • Deciding whether a convenient dependency is worth 40 kB on every page view
  • Finding out why one route's chunk is larger than the whole rest of the app
  • Setting a size budget that a reviewer can see in the build log

More examples

Reading the build report

Two columns per file: the bytes on disk and the bytes after gzip, which is what a user downloads. The entry chunk is the one that blocks first paint, so that is the number to track over time; a lazily loaded view in its own chunk only costs the users who visit it. Vite computes the compressed column because build.reportCompressedSize defaults to true; turning it off makes very large builds finish faster but leaves you guessing.

Example · bash
# npm run build
# (the shape of the report; your filenames, hashes and sizes will differ)
dist/index.html                      0.46 kB | gzip:   0.30 kB
dist/assets/index-DqF3xQ1y.css      12.88 kB | gzip:   3.21 kB
dist/assets/ReportsView-B1n94kPz.js 18.44 kB | gzip:   6.02 kB
dist/assets/index-CkP2a7Vb.js      148.73 kB | gzip:  54.11 kB

Two imports of the same function, two very different costs

Same output, different bundles. The ES module build is what makes the second version shakeable, and the named import is what lets the bundler see the boundary. The same choice shows up across the ecosystem: a package publishing both formats usually documents an -es or /esm entry point, and a package publishing only CommonJS is a size decision you are making on behalf of every user.

Example · ts
// src/lib/slug.ts

// Pulls in a CommonJS build of the whole library, roughly 70 kB minified and
// about 25 kB gzipped, because a bundler cannot statically see which parts are
// unused.
import lodash from 'lodash'

export function slugFromWholeLibrary(text: string): string {
  return lodash.kebabCase(text)
}

// Pulls in kebabCase and its internal helpers only, a few hundred bytes.
import { kebabCase } from 'lodash-es'

export function slug(text: string): string {
  return kebabCase(text)
}

Turning the warning limit into a budget

Lowering chunkSizeWarningLimit turns the report into a budget the build complains about before a reviewer has to notice. It is a warning, not a failure, so it does not break the build on its own; wiring the build log into continuous integration is what makes it enforceable. sourcemap is off by default and worth switching on so the stack traces you get from production errors point at real lines.

Example · ts
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    // Warn earlier than the 500 kB default so growth is visible in review.
    chunkSizeWarningLimit: 300,
    reportCompressedSize: true,
    sourcemap: true
  }
})

Discussion

  • Be the first to comment on this lesson.