Vite 15 ⚡ Code Splitting — Dynamic imports & automatic chunking
Vite’s production build splits the application into multiple JavaScript chunks rather than emitting a single monolithic bundle. The split is driven by the module graph that Rolldown (or Rollup, depending on the Vite version) builds from the entry point. Every dynamic import() expression is a code-splitting point: the imported module and its unique dependencies are emitted as a separate async chunk, loaded only when the expression executes . Everything else—the static imports and the code that runs on initial load—is bundled into the main entry chunk.
This chapter covers how dynamic imports create chunks, how the automatic chunking works without configuration, how to control the chunking behavior with manualChunks or Rolldown’s codeSplitting groups, and how to handle the load errors that occur when a deployment invalidates the old chunks.
Key point: A dynamic import() is the only signal that creates an async chunk by default. A static import is bundled into the importer’s chunk. To split a module that is statically imported, you either convert the import to a dynamic one or configure a manual chunk group that matches the module’s path.
Why dynamic imports drive the split
The static-vs-dynamic distinction. A static import (import { debounce } from 'lodash-es') tells the bundler that the module is needed immediately. Rolldown includes it in the importer’s chunk so that the module is available when the importer executes. A dynamic import (import('./heavy-module.js')) tells the bundler that the module is needed later. The bundler can emit it as a separate file and load it on demand .
The lazy-loading pattern. The canonical use is route-level code splitting. A router component is imported dynamically, and the router framework resolves the import when the user navigates to that route. The initial bundle contains the router and the shared layout, not the route components. The route components are loaded when the route is visited .
// router.js
const routes = [
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue'),
},
]
The () => import(...) returns a Promise. The bundler sees the dynamic import and emits Dashboard.vue and its unique dependencies as a separate chunk. The chunk is fetched when the route is activated, not when the application loads .
The preload hint. Vite injects a module preload link for the dependencies of the dynamic chunk. The browser fetches the chunk’s dependencies in parallel with the chunk itself, reducing the waterfall that would otherwise occur when the chunk is requested. The hint is generated automatically; no configuration is required .
a. The automatic chunking behavior
Without any configuration, the bundler produces a small number of chunks:
| Chunk | Contents |
|---|---|
| Entry | The entry module and everything it statically imports |
| Async | Each dynamic import and its unique dependencies |
| Shared | Modules imported by multiple async chunks, extracted into a shared chunk |
The shared chunk is the automatic optimization. If two async chunks both import the same utility, the bundler extracts the utility into a shared chunk that both async chunks depend on. The utility is fetched once, not twice. This is the default behavior of the bundler’s chunking algorithm, and it is what keeps the total download size from growing with the number of async chunks.
The automatic behavior is usually correct. The cases where it needs adjustment are the cases where the automatic split is too granular (a vendor library ends up duplicated across chunks) or too coarse (a large vendor library ends up in the entry chunk and delays the initial load).
b. Manual chunking with manualChunks
The manualChunks option in the build configuration overrides the automatic split for the modules that match the function or object. It is the mechanism for extracting a vendor library into its own chunk so that the library can be cached independently of the application code.
// vite.config.js
export default defineConfig({
build: {
rolldownOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
return 'vendor'
}
},
},
},
},
})
The function receives the module ID (the resolved file path) and returns the chunk name, or undefined to let the automatic behavior handle it. The id.includes('node_modules') check is the standard pattern for a vendor chunk. All modules from node_modules are placed in a chunk named vendor, and the application code remains in the entry chunk .
The object form is a shorthand for mapping module paths to chunk names:
manualChunks: {
vendor: ['react', 'react-dom'],
}
A manual chunk can still be loaded dynamically. The manual chunk and the dynamic import are not mutually exclusive: the chunk is a grouping of modules, and the dynamic import is the loading mechanism. A module placed in a manual chunk by manualChunks is still loaded when the dynamic import that references it executes . The manual chunk does not force the module to be loaded eagerly.
The splitVendorChunkPlugin was deprecated in Vite 5.2.7. The manualChunks option is the replacement, and the plugin should not be used in new projects .
c. The Vite 8 chunking model
Vite 8 uses Rolldown as the bundler, and Rolldown introduces a new codeSplitting option that supersedes manualChunks for advanced configuration. The manualChunks option is still supported for compatibility, but the new API is the output.codeSplitting object with groups.
// vite.config.js (Vite 8)
export default defineConfig({
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{
name: 'vendor',
test: /node_modules/,
},
],
},
},
},
},
})
The groups array defines chunk groups with a name, a test (a regex or function that matches module IDs), and optional properties like priority and minSize. The priority determines which group wins when a module matches multiple groups. The minSize prevents a group from being created if the resulting chunk is too small to justify a separate request .
The codeSplitting: false value inlines all dynamic imports into a single bundle, which is the equivalent of the deprecated inlineDynamicImports: true. The codeSplitting: true value is the default automatic behavior .
The migration from manualChunks to codeSplitting groups is mechanical for the common vendor-chunk case. The manualChunks function that returns 'vendor' for every module in node_modules becomes a group with name: 'vendor' and test: /node_modules/ .
Complete Example Session
// ============================================
// PART 1: THE DYNAMIC IMPORT
// ============================================
// app.js
document.getElementById('btn').addEventListener('click', () => {
import('./heavy-module.js').then((module) => {
module.heavyFunction()
})
})
// heavy-module.js is emitted as a separate chunk.
// It is fetched only when the button is clicked.
// ============================================
// PART 2: THE ROUTE-LEVEL SPLIT
// ============================================
const routes = [
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue'),
},
]
// The Dashboard component is a separate chunk.
// It is loaded when the route is visited.
// ============================================
// PART 3: THE OUTPUT
// ============================================
// dist/assets/index.a1b2c3.js ← entry chunk
// dist/assets/Dashboard.d4e5f6.js ← async chunk
// dist/assets/heavy-module.g7h8i9.js ← async chunk
// ============================================
// PART 4: THE SHARED CHUNK
// ============================================
// Dashboard.js and heavy-module.js both import lodash-es.
// Rolldown extracts lodash-es into a shared chunk.
// Both async chunks reference the shared chunk.
// dist/assets/lodash-es.j1k2l3.js ← shared chunk
// ============================================
// PART 5: THE MANUAL VENDOR CHUNK
// ============================================
// vite.config.js
export default defineConfig({
build: {
rolldownOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
return 'vendor'
}
},
},
},
},
})
// All node_modules modules are grouped into vendor.js.
// ============================================
// PART 6: THE VITE 8 GROUPS
// ============================================
export default defineConfig({
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{
name: 'vendor',
test: /node_modules/,
},
],
},
},
},
},
})
// The equivalent of the manualChunks vendor split.
// ============================================
// PART 7: THE PRIORITY GROUP
// ============================================
codeSplitting: {
groups: [
{ name: 'react', test: /node_modules[\\/]react/, priority: 20 },
{ name: 'vendor', test: /node_modules/, priority: 10 },
],
}
// React is split into its own chunk.
// Other node_modules modules go to the vendor chunk.
// ============================================
// PART 8: THE SIZE-BASED SPLIT
// ============================================
codeSplitting: {
groups: [
{
name: 'vendor',
test: /node_modules/,
minSize: 100000,
maxSize: 250000,
},
],
}
// The vendor group is split into chunks between 100KB and 250KB.
// ============================================
// PART 9: THE LOAD ERROR HANDLER
// ============================================
window.addEventListener('vite:preloadError', (event) => {
window.location.reload()
})
// Handles the stale-chunk case after a deployment.
// ============================================
// PART 10: THE DECISION TREE
// ============================================
// Is the module needed on initial load?
// Yes → static import (entry chunk)
// No → dynamic import (async chunk)
//
// Is a vendor library duplicated across chunks?
// Yes → manualChunks or codeSplitting group
The ten parts covered the dynamic import, the route-level split, the output, the shared chunk, the manual vendor chunk, the Vite 8 groups, the priority group, the size-based split, the load error handler, and the decision tree.
Quick Reference
Chunking mechanisms
| Mechanism | Trigger | Result |
|---|---|---|
Dynamic import() | Explicit in code | Async chunk |
| Static import | Default | Bundled into importer’s chunk |
| Automatic shared | Module used by multiple async chunks | Shared chunk |
manualChunks | Config function/object | Named chunk group |
codeSplitting groups | Config object (Vite 8) | Named chunk group with priority |
Configuration options
| Option | Vite version | Purpose |
|---|---|---|
build.rollupOptions.output.manualChunks | Vite 5–7 | Manual chunk grouping |
build.rolldownOptions.output.manualChunks | Vite 8 | Manual chunk grouping (compatible) |
build.rolldownOptions.output.codeSplitting | Vite 8 | Advanced chunk grouping |
splitVendorChunkPlugin | Deprecated | Do not use |
The vendor chunk pattern
| Version | Configuration |
|---|---|
| Vite 5–7 | manualChunks: (id) => id.includes('node_modules') ? 'vendor' : undefined |
| Vite 8 | codeSplitting: { groups: [{ name: 'vendor', test: /node_modules/ }] } |
Load error handling
| Event | Purpose |
|---|---|
vite:preloadError | Fires when a dynamic import fails |
event.payload | The original import error |
event.preventDefault() | Prevents the error from being thrown |
Best Practices
✅ Do This:
// Use dynamic imports for route components
component: () => import('./views/Dashboard.vue') // ✅
// Extract vendor modules into a separate chunk
manualChunks: (id) => id.includes('node_modules') ? 'vendor' : undefined // ✅
// Use the Vite 8 groups API for new projects
codeSplitting: { groups: [{ name: 'vendor', test: /node_modules/ }] } // ✅
// Add the preloadError handler
window.addEventListener('vite:preloadError', () => location.reload()) // ✅
❌ Don’t Do This:
// Statically import a route component
import Dashboard from './views/Dashboard.vue' // ❌ (no split)
// Use splitVendorChunkPlugin
plugins: [splitVendorChunkPlugin()] // ❌ (deprecated)
// Assume a manual chunk is loaded eagerly
// A manual chunk is a grouping, not a loading mechanism // ❌
// Ignore the preloadError event
// Users see a broken page after a deployment // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| No async chunk generated | Dynamic import path is a variable | Use a static string or a comment |
| Vendor code in the entry chunk | manualChunks not configured | Add the vendor group |
| Chunk loaded eagerly despite manualChunks | Manual chunk is a grouping, not a loading mode | The dynamic import controls loading; the manual chunk controls grouping |
| Old chunk 404 after deploy | HTML cached, old assets deleted | Serve HTML with no-cache; add preloadError handler |
| Too many small chunks | Automatic split too granular | Use manualChunks or minSize in the group |
Real-World Examples
1. Dynamic import
import('./heavy-module.js').then((m) => m.run())
2. Route-level split
component: () => import('./views/Dashboard.vue')
3. Manual vendor chunk
manualChunks: (id) => id.includes('node_modules') ? 'vendor' : undefined
4. Object-form manual chunk
manualChunks: { vendor: ['react', 'react-dom'] }
5. Vite 8 vendor group
codeSplitting: { groups: [{ name: 'vendor', test: /node_modules/ }] }
6. Priority group
{ name: 'react', test: /node_modules[\\/]react/, priority: 20 }
7. Size-based group
{ name: 'vendor', test: /node_modules/, minSize: 100000, maxSize: 250000 }
8. Preload error handler
window.addEventListener('vite:preloadError', () => location.reload())
9. The shared chunk
dist/assets/shared.j1k2l3.js
10. The decision
Initial load? → static import
Lazy load? → dynamic import
Vendor? → manualChunks or codeSplitting group
Visual
The dynamic import split
┌─────────────────────────────────────────────────────────────┐
│ main.js │
│ ├── static import lodash ──► entry chunk │
│ └── dynamic import heavy ──► async chunk │
│ │
│ dist/assets/index.a1b2c3.js ← entry + lodash │
│ dist/assets/heavy.d4e5f6.js ← heavy module │
└─────────────────────────────────────────────────────────────┘
The shared chunk
┌─────────────────────────────────────────────────────────────┐
│ Dashboard.js ──┐ │
│ ├── import lodash ──► shared chunk │
│ heavy-module.js┘ │
│ │
│ dist/assets/lodash.j1k2l3.js ← shared │
│ dist/assets/Dashboard.g7h8i9.js ← async │
│ dist/assets/heavy.k1l2m3.js ← async │
└─────────────────────────────────────────────────────────────┘
The vendor split
┌─────────────────────────────────────────────────────────────┐
│ Without manualChunks: │
│ vendor code in the entry chunk or duplicated │
│ │
│ With manualChunks: │
│ all node_modules → vendor chunk │
│ app code → entry chunk │
│ vendor is cached independently │
└─────────────────────────────────────────────────────────────┘
The stale chunk problem
┌─────────────────────────────────────────────────────────────┐
│ User has old HTML with reference to chunk.a1b2c3.js │
│ │ │
│ ▼ │
│ New deployment deletes chunk.a1b2c3.js │
│ │ │
│ ▼ │
│ User triggers dynamic import ──► 404 │
│ │ │
│ ▼ │
│ vite:preloadError fires ──► reload ──► new HTML + chunks │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Code-splitting trigger | Dynamic import() |
| Automatic behavior | Shared chunks for modules used by multiple async chunks |
| Manual chunking (Vite 5–7) | build.rollupOptions.output.manualChunks |
| Manual chunking (Vite 8) | build.rolldownOptions.output.codeSplitting.groups |
| Deprecated plugin | splitVendorChunkPlugin |
| Vendor chunk check | id.includes('node_modules') |
| Load error event | vite:preloadError |
| Stale asset fix | Cache-Control: no-cache on HTML |
Key takeaways:
- A dynamic
import()is the code-splitting point. The imported module and its unique dependencies are emitted as a separate async chunk, loaded when the expression executes. A static import is bundled into the importer’s chunk . - The bundler extracts shared modules automatically. A module used by multiple async chunks is placed in a shared chunk that both async chunks depend on. This prevents duplication without configuration.
manualChunksoverrides the automatic split for matching modules. The standard pattern is a function that returns'vendor'for every module innode_modules, which extracts the vendor libraries into a chunk that can be cached independently of the application code .- A manual chunk is a grouping, not a loading mode. A module placed in a manual chunk by
manualChunksis still loaded when the dynamic import that references it executes. The manual chunk does not force the module to be loaded eagerly . - Vite 8 introduces
codeSplittinggroups. Thegroupsarray is the replacement formanualChunksin new projects. Each group has aname, atest, and optional properties likepriorityandminSize. - The
vite:preloadErrorevent handles stale chunks. When a deployment deletes the old assets, a dynamic import of a deleted chunk fails and the event fires. The recommended handler reloads the page to fetch the new assets. The HTML must be served withCache-Control: no-cache.
Remember: Code splitting is controlled by the import syntax. A dynamic import() creates an async chunk; a static import does not. The automatic behavior extracts shared modules into a shared chunk. The manual configuration—manualChunks in Vite 5–7, codeSplitting groups in Vite 8—extracts the vendor libraries into a chunk that is cached independently. The splitVendorChunkPlugin is deprecated. And the vite:preloadError event is the safety net for the stale-chunk problem that every hashed-asset deployment eventually encounters.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!