Loading Code When It Is Needed
How to choose what to code-split - a route, a modal, a heavy chart - and how to measure whether the split actually helped.
component: () => import('../views/ReportsView.vue')Code splitting means putting part of your application in a separate JavaScript file that downloads later. In Vite, every import() call is a split point: the module behind it becomes its own chunk. Nothing else is needed, no plugin and no configuration.
What is worth splitting
Three things repay the effort. A route most visitors never open: give the route a component that is a function returning import(). A modal or panel behind a click. A heavy dependency - a chart, an editor, a date library - used on one screen. What is not worth splitting: anything on the first screen. Splitting that turns one download into two round trips and makes the first paint later.
Measure, do not guess
Run the production build and read its report. Vite prints each chunk with its raw and gzip size, and warns on any chunk over build.chunkSizeWarningLimit, which defaults to 500 kB. Write down the entry chunk size, make one change, build again, compare. Then open the Network panel with cache disabled and confirm the chunk arrives on the interaction you expected and not on load.
What trips people up
A dependency imported by both a lazy chunk and the entry chunk stays in the entry chunk, so the saving disappears while the import that caused it is still there. A split also costs a request at the moment the user acts, which on a slow connection is a visible pause; that is what loadingComponent and delay are for.
Example
<script setup lang="ts">
import { defineAsyncComponent, ref } from 'vue'
// This import() is the split point: JsonEditor.vue and everything it imports
// become a chunk that downloads on the first click of Advanced.
const JsonEditor = defineAsyncComponent(() => import('./JsonEditor.vue'))
const tab = ref<'general' | 'advanced'>('general')
</script>
<template>
<nav>
<button type="button" @click="tab = 'general'">General</button>
<button type="button" @click="tab = 'advanced'">Advanced</button>
</nav>
<p v-if="tab === 'general'">Nothing to configure here yet.</p>
<json-editor v-else />
</template>
<style scoped>
nav {
display: flex;
gap: 0.5rem;
}
</style>When to use it
- Keep an admin section out of the bundle that ordinary users download.
- Defer a rich text editor until the user clicks Edit.
- Prove to a reviewer that a split reduced the first download, with before-and-after numbers.
More examples
Lazy route components
Routes are the easiest split to get right, because the router already knows when each view is needed. Note the shape: the component is the function itself, not defineAsyncComponent(fn). The router documentation is explicit that route components should not be wrapped in defineAsyncComponent - lazy loading and async components are separate features, and wrapping adds a second layer of promise handling the router does not expect.
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// Eager: this is the first screen, so splitting it would only add a request.
{ path: '/', name: 'home', component: HomeView },
// Lazy: a function returning import(). The router calls it the first time
// the route is entered and caches the result.
{
path: '/reports',
name: 'reports',
component: () => import('../views/ReportsView.vue')
},
{
path: '/admin',
name: 'admin',
component: () => import('../views/AdminView.vue')
}
]
})Reading the build report
This is the whole measurement loop. Compare the entry chunk, not the sum of all chunks, because the sum is what a visitor who uses every screen downloads and the entry is what everyone downloads. If the entry chunk barely moved, the dependency you tried to defer is still imported somewhere on the first screen - search for it before trying another split.
# npm run build, before and after splitting the reports view
# before
dist/assets/index-DqF2xq1b.js 412.80 kB | gzip: 131.02 kB
# after
dist/assets/index-BrT9kk0a.js 268.14 kB | gzip: 86.41 kB
dist/assets/ReportsView-C1xZ9f.js 147.92 kB | gzip: 44.77 kB
# The number that matters is the entry chunk: 131 kB gzip down to 86 kB.
# The total went up slightly, which is fine - nobody downloads the total.Build options that affect the report
These three options are what you touch while working on bundle size. One version note: in Vite 8 the bundler-level option is build.rolldownOptions, and build.rollupOptions is kept as an alias of it - older articles configure chunking through rollupOptions, and that still resolves, but check the current option name before copying a config wholesale.
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
// Size in kB above which Vite warns about a chunk. 500 is the default.
chunkSizeWarningLimit: 500,
// Gzip sizes in the report. Default true; turning it off speeds up CI builds.
reportCompressedSize: true,
// Needed to read a production stack trace back to your source.
sourcemap: true
}
})
Discussion