Configuring code splitting with Vite and Rollup correctly
A single giant JavaScript bundle slows down every Vue app on first load. Bundle splitting with dynamic import, manualChunks and route based code splitting spreads the code into smaller pieces the browser can load in parallel and on demand.
Table of Contents
- 1. Why bundle splitting matters for Vue apps
- 2. How Vite and Rollup form chunks by default
- 3. Dynamic import: the basic building block for code splitting
- 4. manualChunks: splitting vendor code deliberately
- 5. Route based splitting with Vue Router
- 6. Component level splitting with defineAsyncComponent
- 7. Analyzing chunk sizes with the visualizer
- 8. Common mistakes in bundle splitting
- 9. Splitting strategies compared directly
- 10. Summary
- 11. FAQ
1. Why bundle splitting matters for Vue apps
Bundle splitting describes splitting a JavaScript bundle into several smaller files that the browser downloads individually and on demand, instead of downloading everything at once on the first page visit. Without bundle splitting, a Vue app grows with every new route, every new library and every new component into a single file that must be fully loaded and parsed even for the home page, before anything interactive appears on screen.
The effect is especially noticeable on mobile connections and in larger applications: an uncompressed one megabyte bundle can mean several extra seconds of load time on a mediocre mobile connection before Vue can even start the hydration process. Bundle splitting addresses exactly this problem by loading only the code that the current route and the currently visible components actually need. The rest is moved into separate chunks and loaded on demand via dynamic import.
Vite uses Rollup under the hood for the production build and already ships with sensible defaults. For most Vue apps that is not enough once third party libraries such as chart libraries, rich text editors or large icon sets come into play. The following sections show how to control bundle splitting for Vue apps deliberately through the Vite configuration, the router and individual components.
2. How Vite and Rollup form chunks by default
Without additional configuration, Rollup already creates separate chunks automatically for every dynamic import used in the code. Every route loaded via () => import('./views/Dashboard.vue') instead of a static import lands in its own chunk. This is called automatic chunking and is the simplest entry point into bundle splitting for Vue apps, since it requires no extra configuration, just a different import style.
It becomes a problem when several routes import the same third party library. Without explicit control, Rollup can in some configurations duplicate shared code across multiple chunks instead of extracting it once into a common chunk. The result: users download the same library code repeatedly when navigating between routes, because each route chunk contains its own copy. This is exactly where manualChunks comes in, covered in the next section.
// vite.config.js — baseline configuration, no manual chunking yet
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
// Rollup already splits dynamic imports into separate chunks by default
rollupOptions: {
output: {
// Chunk file naming, useful for cache busting and debugging
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js'
}
}
}
})
3. Dynamic import: the basic building block for code splitting
The import() expression is the native JavaScript syntax that every bundle splitting approach in Vue apps builds on. Unlike a static import at the top of a file, import() returns a Promise and is automatically recognized by Rollup as a chunk boundary. Vue Router, Pinia stores and individual components can all be loaded dynamically the same way, which makes bundle splitting a consistent pattern across the entire codebase instead of a special case just for routes.
An important detail about dynamic import: Rollup does not honor comments like /* webpackChunkName: "..." */, that is Webpack specific syntax. For named chunks in Vite you use the file name itself or the manualChunks function instead. Anyone coming from a Webpack migration should remove these comments, they are simply ignored by Rollup and have no effect whatsoever on bundle splitting.
// router/index.js — every route component is a separate dynamic import
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
component: () => import('../views/Home.vue')
},
{
path: '/dashboard',
// Heavy view with charts — only loaded when the user navigates here
component: () => import('../views/Dashboard.vue')
},
{
path: '/settings',
component: () => import('../views/Settings.vue')
}
]
export const router = createRouter({
history: createWebHistory(),
routes
})
// Same pattern works for conditional feature loading outside the router
async function loadExportFeature() {
const { generatePdfExport } = await import('../features/pdf-export.js')
return generatePdfExport
}
4. manualChunks: splitting vendor code deliberately
manualChunks in the Rollup configuration allows explicit control over which modules end up in which chunk. Instead of relying on automatic detection, you define a function that returns the target chunk name for each processed module. The most common pattern: extracting all node_modules dependencies into a shared vendor chunk, so application code and third party code can be cached separately. If only the application code changes between two deployments, the browser does not need to re download the vendor chunk.
For larger Vue apps, a finer split is worthwhile: a dedicated chunk for Vue itself and Vue Router, a separate chunk for large, rarely used libraries such as PDF generators or rich text editors, and a third chunk for frequently used, small utility libraries. This granular bundle splitting strategy prevents a single large library from bloating the entire vendor chunk and making caching inefficient for all other dependencies, since any small change to one library would invalidate the whole vendor chunk.
// vite.config.js — manual chunk strategy for vendor code separation
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('vue-router') || id.includes('/vue/')) {
return 'vendor-vue'
}
if (id.includes('chart.js') || id.includes('pdfmake')) {
// Heavy, rarely used libraries get their own chunk
return 'vendor-heavy'
}
// Everything else goes into a shared vendor chunk
return 'vendor'
}
}
}
},
chunkSizeWarningLimit: 500 // KB — warn earlier than the 500KB Rollup default suggests
}
})
5. Route based splitting with Vue Router
Route based bundle splitting has the biggest impact for most Vue apps, because users rarely visit every route of an application in a single session. Declaring every route as a dynamic import is the first step, but it is not enough when several routes share the same heavy dependencies. In that case, grouping related routes into the same chunk through matching chunk names in import(/* @vite-ignore */) calls or through the manualChunks function from the previous section pays off.
A frequently overlooked aspect of route splitting: Vue Router starts loading a route's chunk already at the beginning of navigation, not only after navigation completes. This means a slow network request during navigation causes a visible delay if no loading indicator is built in. Combining router.beforeEach for a global progress bar with route based bundle splitting produces behavior that feels fast even while the actual chunk is still loading.
// router/index.js — loading indicator during route chunk fetch
import { createRouter, createWebHistory } from 'vue-router'
import NProgress from 'nprogress'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/reports', component: () => import('../views/Reports.vue') },
{ path: '/reports/export', component: () => import('../views/ReportsExport.vue') }
]
})
router.beforeEach(() => {
NProgress.start() // visible progress bar while the route chunk downloads
})
router.afterEach(() => {
NProgress.done()
})
export default router
6. Component level splitting with defineAsyncComponent
Not every split needs to happen at the route level. defineAsyncComponent allows individual heavy components inside a route to be loaded only once they are actually rendered. A typical example: a modal dialog with a complex form that only becomes visible after a button click. Without component splitting, the user downloads the code for this modal already on the first visit to the route, even if they never click the button.
defineAsyncComponent also supports options for loading state, error state and timeout, which matters especially on slow connections. For bundle splitting in Vue apps, this component level granularity is the final refinement step after route splitting: first split the large routes, then identify the heaviest, rarely used components inside the biggest routes and load them separately.
// components/ReportEditor.vue — lazy-loaded heavy component with states
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
import ErrorFallback from './ErrorFallback.vue'
const RichTextEditor = defineAsyncComponent({
loader: () => import('./RichTextEditor.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorFallback,
delay: 200, // avoid spinner flash for fast connections
timeout: 8000 // fall back to error state after 8 seconds
})
export default {
components: { RichTextEditor },
data() {
return { showEditor: false }
}
}
7. Analyzing chunk sizes with the visualizer
Bundle splitting without measurement is guesswork. The rollup-plugin-visualizer plugin generates an interactive treemap view after every build, showing how large every chunk is and which modules make it up. For bundle splitting in Vue apps, this is the decisive step to find out which library actually accounts for the largest share of the bundle, instead of optimizing based on guesses.
A typical result after the first analysis: a single icon library imported in full instead of only the icons actually needed suddenly accounts for twenty percent of the main bundle. Or a date library with full locale support gets imported even though only a single language is needed. These insights translate directly into targeted manualChunks rules or more tree shaking friendly imports.
// vite.config.js — chunk size visualization
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
vue(),
visualizer({
filename: './dist/stats.html',
gzipSize: true,
brotliSize: true,
template: 'treemap' // interactive treemap after every build
})
]
})
// Run: npm run build
// Then open dist/stats.html to inspect chunk composition
8. Common mistakes in bundle splitting
The most common mistake is excessive splitting: wrapping even the smallest component in its own dynamic import creates hundreds of tiny chunks whose HTTP overhead eats up the savings in actual file size. Even with HTTP/2 multiplexing, every additional request costs minimal but measurable time for connection setup and header processing. A reasonable rule of thumb: chunks under five kilobytes are rarely worth their own chunk, unless they are loaded extremely rarely.
A second common mistake concerns circular dependencies between chunks. If chunk A imports something from chunk B and vice versa, Rollup may have to merge both chunks or produces an inefficient load order where both chunks wait on each other. The rollup-plugin-visualizer often shows such entanglements as surprisingly large, mutually dependent chunks. A third mistake: manualChunks rules that are too coarse and accidentally pack code into a chunk needed on almost every route, which undermines the supposed benefit of bundle splitting since the chunk has to be loaded almost every time anyway.
9. Splitting strategies compared directly
The following overview ranks the most important bundle splitting strategies by granularity, typical result and suitable use case for Vue apps of different sizes.
| Strategy | Granularity | Effect | Suitable for |
|---|---|---|---|
| No splitting | One bundle | Slow initial load | Very small prototypes |
| Route splitting | Per route | Much smaller initial load | Almost all multi page apps |
| manualChunks vendor | Per dependency group | Better long term caching | Apps with frequent deployments |
| Component splitting | Per heavy component | Smaller route chunks | Modals, editors, charts |
| Excessive splitting | Per small component | HTTP overhead outweighs benefit | Not recommended |
The pragmatic order for bundle splitting in Vue apps: first introduce route splitting, then identify the largest chunks with the visualizer, then apply targeted manualChunks for vendor code, and finally component level splitting for individual heavy components inside the biggest routes. Every step should be verified with another visualizer analysis, instead of blindly creating more chunks.
Mironsoft
Vite build optimization and bundle splitting for Vue apps
Vue app with a slow initial load due to a giant bundle?
We analyze your chunk structure with the visualizer, configure manualChunks deliberately and introduce route and component splitting, for measurably shorter load times on mobile connections.
Chunk analysis
Treemap analysis with rollup-plugin-visualizer and concrete action items
Vite configuration
manualChunks, chunk naming and cache strategies for faster deployments
Route & component splitting
Dynamic imports and defineAsyncComponent for heavy UI areas
10. Summary
Bundle splitting for Vue apps starts with the simplest measure: declaring every route as a dynamic import instead of a static one. Vite and Rollup automatically create separate chunks from that, without any extra configuration needed. For vendor code, manualChunks is worthwhile to separate application code from third party code for more efficient caching. defineAsyncComponent completes the picture at the component level for heavy, rarely used UI areas such as modals and rich text editors.
The decisive step in any bundle splitting effort is measuring with rollup-plugin-visualizer. Without this analysis, bundle splitting stays guesswork, with it, it becomes visible which library actually accounts for the largest share of the bundle and where a targeted split truly pays off. Excessive splitting with hundreds of tiny chunks reverses the effect and should be avoided.
Bundle Splitting for Vue Apps, the essentials at a glance
Route splitting first
Declare every route as a dynamic import, the biggest lever with the least configuration effort.
manualChunks for vendor
Separate third party code from application code for more stable browser caching across deployments.
Component splitting
defineAsyncComponent for modals, editors and charts that are not visible on every page visit.
Measure with the visualizer
rollup-plugin-visualizer shows real chunk sizes instead of optimizing based on guesses.