| | |

Vite 15 ⚡ Code Splitting — Dynamic imports & automatic chunking

⏱️ Estimated read: 12 min | |

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:

ChunkContents
EntryThe entry module and everything it statically imports
AsyncEach dynamic import and its unique dependencies
SharedModules 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

MechanismTriggerResult
Dynamic import()Explicit in codeAsync chunk
Static importDefaultBundled into importer’s chunk
Automatic sharedModule used by multiple async chunksShared chunk
manualChunksConfig function/objectNamed chunk group
codeSplitting groupsConfig object (Vite 8)Named chunk group with priority

Configuration options

OptionVite versionPurpose
build.rollupOptions.output.manualChunksVite 5–7Manual chunk grouping
build.rolldownOptions.output.manualChunksVite 8Manual chunk grouping (compatible)
build.rolldownOptions.output.codeSplittingVite 8Advanced chunk grouping
splitVendorChunkPluginDeprecatedDo not use

The vendor chunk pattern

VersionConfiguration
Vite 5–7manualChunks: (id) => id.includes('node_modules') ? 'vendor' : undefined
Vite 8codeSplitting: { groups: [{ name: 'vendor', test: /node_modules/ }] }

Load error handling

EventPurpose
vite:preloadErrorFires when a dynamic import fails
event.payloadThe 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

PitfallWhy It HappensFix
No async chunk generatedDynamic import path is a variableUse a static string or a comment
Vendor code in the entry chunkmanualChunks not configuredAdd the vendor group
Chunk loaded eagerly despite manualChunksManual chunk is a grouping, not a loading modeThe dynamic import controls loading; the manual chunk controls grouping
Old chunk 404 after deployHTML cached, old assets deletedServe HTML with no-cache; add preloadError handler
Too many small chunksAutomatic split too granularUse 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

ItemValue
Code-splitting triggerDynamic import()
Automatic behaviorShared 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 pluginsplitVendorChunkPlugin
Vendor chunk checkid.includes('node_modules')
Load error eventvite:preloadError
Stale asset fixCache-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.
  • manualChunks overrides the automatic split for matching modules. The standard pattern is a function that returns 'vendor' for every module in node_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 manualChunks is 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 codeSplitting groups. The groups array is the replacement for manualChunks in new projects. Each group has a name, a test, and optional properties like priority and minSize .
  • The vite:preloadError event 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 with Cache-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.

Was this helpful?


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!