Halving Your Vite 6.x Bundle Size with manualChunks and Dynamic Import Strategies
The bigger a project grows, the more you find yourself running pnpm build and heading off to brew a cup of coffee. I used to accept it as inevitable back when I was waiting over a minute with Webpack, but switching to Vite resolved all that frustration at once. Yet even with Vite, if you just trust the default configuration, you'll often find the initial bundle comes out larger than expected, or that browser caching isn't being utilized effectively after deployment.
In this article, we'll look at how to cut initial bundle size by 40–60% and improve cache hit rates by combining two approaches: separating vendor chunks with manualChunks and applying route-based dynamic imports — with real configuration code. If you have an SPA project with multiple routes, you could be applying this today. Add the Environment API newly introduced in Vite 6, and you can enjoy the same optimization experience even when developing for SSR or Edge runtimes.
This article is aimed at frontend developers who are already using Vite in their projects. If you're considering migrating from Webpack, the comparison content and migration notes in the pros and cons section should be helpful.
What this article covers:
- Vite's dual-pipeline architecture and how dependency pre-bundling works
- Optimizing vendor caching with
manualChunks(including circular reference pitfalls)- Reducing initial bundle size with route-based dynamic imports
- Eliminating unnecessary polyfills with
build.targetconfiguration- Overview of the Vite 6 Environment API
- An integrated
vite.config.tsexample combining all four configurations
Core Concepts
Vite's Dual-Pipeline Architecture
Vite is fast not simply because it's "a modern tool." It's thanks to a dual-pipeline architecture that handles the dev server and production build in completely different ways.
| Mode | Engine | Approach |
|---|---|---|
| Dev server | esbuild + native ESM | Transforms modules on request without bundling |
| Production build | esbuild (transpile) + Rollup (bundle) | Generates optimized chunk files |
Source files
↓
esbuild — TypeScript/JSX transpile
↓
Rollup — tree shaking, chunk splitting, hash generation
↓
dist/ outputDuring development, rather than bundling thousands of modules, Vite handles the browser's ESM (ES Modules) requests directly as they come in. This means server startup is fast, and HMR (Hot Module Replacement — a feature that swaps only the changed module on save, refreshing the screen without a full reload) is near-instant at 10–20ms. At deployment time, Rollup handles thorough optimization.
There's one important gotcha here. Dev runs on unbundled ESM while prod is bundled with Rollup. Because of this difference, features that work fine with vite dev can occasionally break after vite build. Making a habit of verifying the production build locally with vite preview before deploying will help you avoid this trap.
Tree Shaking: A technique that removes unused code from the bundle. Rollup leverages the static analysis characteristics of ES modules to keep only the symbols that are actually referenced among those
imported. If tree shaking isn't working as expected, it's worth checking whether the library'spackage.jsonhas"sideEffects": falseset. Without this, Rollup takes a conservative approach and may leave unused code in the bundle.
Dependency Pre-bundling
When you first start the dev server, Vite takes some time to scan node_modules. This is the pre-bundling phase. It uses esbuild to convert CommonJS modules to ESM and merges packages with hundreds of internal modules — like lodash — into a single cached file.
Without this step, the browser would fire hundreds of network requests just to load lodash. The pre-bundling output is stored in node_modules/.vite/deps/ and won't re-run unless dependencies change.
Code Splitting and Chunk Strategy
Rollup creates a chunk boundary every time it encounters a dynamic import(). Two strategies combine here:
- Automatic code splitting: Chunks are automatically generated at each dynamic import point
manualChunks: Developers explicitly specify "which modules go into which chunk"
Combining these two means that even when app code changes, library chunk hashes remain the same, allowing far more effective use of the browser cache.
Vite 6 Environment API
Honestly, my first reaction to this feature was "why do we need this?" — but it's incredibly useful when developing alongside SSR or Edge runtimes.
Up through Vite 5, only client and ssr environments were implicitly supported, but Vite 6 opens this up into an explicit and extensible structure. It becomes possible to provide the same HMR experience on non-Node runtimes like Cloudflare Workers and Vercel Edge.
Environment API status: As of Vite 6, this is still tagged as
experimentaland is expected to stabilize in Vite 7. Keep this in mind when applying it to important production features.
Practical Application
Example 1: Optimizing Vendor Caching with manualChunks
This is the configuration with the most immediate impact. It's a situation you encounter frequently in practice — with default settings, React and your app code end up in the same chunk, meaning users have to re-download hundreds of KB of React code every time you change a single line in a component.
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
// React core in its own chunk — rarely changes, so cache stays intact
if (id.includes('react') || id.includes('react-dom')) {
return 'vendor-react'
}
// shadcn/ui is copy-based, not an npm package,
// so specify the actual installed dependency package names
if (
id.includes('@radix-ui') ||
id.includes('class-variance-authority') ||
id.includes('cmdk')
) {
return 'vendor-ui'
}
// All other third-party dependencies
return 'vendor'
}
},
},
},
},
})| Chunk name | Contents | When hash changes |
|---|---|---|
vendor-react |
react, react-dom | Only on React version upgrade |
vendor-ui |
@radix-ui, class-variance-authority, etc. | Only on UI library update |
vendor |
Other node_modules | Only on third-party dependency change |
index |
App code | On every code change |
With this separation, no matter how frequently you deploy app code, the vendor-react chunk remains cached in users' browsers.
Caution — Circular Chunk Dependency: When using
manualChunksin function form, if chunk A references chunk B and chunk B references chunk A in return, Rollup will fail the build or emit warnings. This commonly occurs when shared utility functions are imported across multiple chunks. The fix is to extract shared modules into a separatesharedchunk.
Example 2: Route-Based Dynamic Imports
This is the most direct way to reduce initial bundle size. There's no reason for users to download code for pages they haven't visited yet. In practice, applying this pattern often reduces initial bundle size by 40–60%. The "half" promised in the title is based on exactly this figure.
// App.tsx
import React, { Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
const Dashboard = React.lazy(() => import('./pages/Dashboard'))
const Settings = React.lazy(() => import('./pages/Settings'))
const Analytics = React.lazy(() => import('./pages/Analytics'))
export default function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
)
}Each import() call becomes a Rollup chunk boundary, resulting in separate JS files per page in the build output. Pick one large page component from your bundle analysis results and try applying this first — you'll feel the effect immediately.
Example 3: Removing Unnecessary Polyfills with build.target
Clearly defining your supported browser range lets Vite minimize polyfills and reduce bundle size.
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
// For modern browsers only
target: 'es2022',
},
})For example, targeting es2022 removes polyfills for Optional Chaining (?.), Nullish Coalescing (??), Array.prototype.at(), and other syntax that browsers already support natively. If you use this syntax broadly throughout your code, the polyfill removal savings can be substantial.
Choosing
build.target:es2022targets major browsers released after 2022. If you need to support older browsers, using@vitejs/plugin-legacyalongside this is recommended.
Example 4: Simultaneous Edge/SSR Development with Environment API
For projects deploying to Cloudflare Workers or Vercel Edge, you can explicitly configure per-environment module resolve conditions.
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
environments: {
ssr: {
resolve: {
conditions: ['node'],
},
},
edge: {
resolve: {
// Cloudflare Workers runtime
conditions: ['workerd'],
},
},
},
})You can run multiple environments simultaneously from a single dev server and still get HMR, eliminating the hassle of spinning up separate servers per environment as before.
Integrated Example: Everything in One vite.config.ts
A complete example showing how to combine all the above configurations in a real project, including the bundle visualization plugin.
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
react(),
// Automatically opens a stats.html chunk treemap after build
visualizer({ open: true }),
],
build: {
target: 'es2022',
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('react') || id.includes('react-dom')) {
return 'vendor-react'
}
if (
id.includes('@radix-ui') ||
id.includes('class-variance-authority') ||
id.includes('cmdk')
) {
return 'vendor-ui'
}
return 'vendor'
}
},
},
},
},
environments: {
ssr: {
resolve: { conditions: ['node'] },
},
},
})This single file covers vendor chunk separation, polyfill minimization, and bundle visualization all at once. The visualizer plugin automatically opens stats.html after the build, showing the chunk structure as a treemap. When applying this for the first time, looking at this treemap first is recommended.
Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| Dev server startup speed | Native ESM approach — even projects with thousands of modules are ready in 1–2 seconds |
| HMR response time | Only the changed module is swapped — near-instant reflection at 10–20ms |
| Chunk strategy flexibility | Maximize cache hit rate by combining automatic code splitting with manualChunks |
| Environment API | Integrated management of SSR/Edge/browser from a single server |
| Rolldown introduction | In Vite 8 Beta (releasing December 2025), the Rust-based Rolldown is officially adopted as the production bundler. 10–30× build speed improvement over Rollup |
| Speed vs. Webpack | Cold start ~1.2s vs. Webpack 5's ~7s, HMR 10–20ms vs. 500ms–1.6s |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Dev/production behavior mismatch | Intermittent bugs due to dev running as unbundled ESM vs. prod as Rollup bundle | Make a habit of verifying the production build locally with vite preview |
| Large-project cold start | Reports of Vite 6–7 cold start performance degradation in monorepos with tens of thousands of modules | Consider upgrading to Rolldown-based Vite 8 |
| Barrel file problem | Re-exporting everything through index.ts causes explosive module analysis costs |
Prefer direct path imports (import { Foo } from './components/Foo') |
| Environment API stability | Experimental status as of Vite 6 | Consider applying critical production features after Vite 7 stabilization |
| Excessive chunk splitting | Performance can actually degrade when exceeding 20–30 chunks in non-HTTP/2 environments | Monitor chunk count periodically with rollup-plugin-visualizer |
| manualChunks circular references | Circular dependencies between chunks can occur with function-based usage | Extract shared modules into a separate shared chunk |
Barrel File: An
index.tspattern usingexport * from './ComponentA'to aggregate and re-export submodules from one place. Convenient, but Vite (Rollup) must process all submodules to analyze actual usage — as scale grows, build analysis costs surge.
If you're considering migrating from Webpack
While Vite is overwhelmingly faster, there are a few things to watch out for during migration. CommonJS require() syntax doesn't work directly in Vite and needs to be converted to ESM. If you're using Webpack's require.context or custom loaders, check first whether they can be replaced with Vite plugins. After migration, always validate production behavior with vite build && vite preview.
The Most Common Mistakes in Practice
-
Building without
manualChunks— The default configuration works fine, but library and app code get mixed together, meaning users re-download all chunks on every deployment. Setting upmanualChunksalone makes a significant difference in cache efficiency. -
Testing only with
vite devwithoutvite preview— Because the dev server and production build have different bundling approaches, things that work in dev can break after building. Differences can appear especially in dynamic import paths and CSS handling, so verifying withvite build && vite previewbefore deployment is the safer approach. -
Letting chunks multiply unchecked — Applying dynamic imports to every single component can result in dozens of chunk files. Regularly visualizing the chunk treemap with
rollup-plugin-visualizerorvite-bundle-analyzerto check the overall structure is recommended.
Closing Thoughts
Three steps you can start right now:
-
Start by assessing your bundle — Install
pnpm add -D rollup-plugin-visualizer, addvisualizer({ open: true })tovite.config.tsas in the integrated example above, then runpnpm build. You'll get a treemap showing at a glance which libraries are consuming bundle size. -
Separate vendor chunks with
manualChunks— After looking at the treemap, try separating rarely-changing dependencies like React and UI libraries into dedicated chunks. From the next deployment on, browser cache efficiency will be noticeably improved. Trying to apply everything at once can get complicated, so it's recommended to start with the largest dependencies one by one. -
Apply dynamic imports to heavy pages — Attaching
React.lazy()andSuspenseat the route level is the fastest way to reduce initial bundle size. Pick one large page component from the treemap and try applying it first — you'll be able to confirm the 40–60% reduction effect directly.
The most helpful resources while writing this article were Soledad Penadés's post on manualChunks caching and the official Vite Performance documentation. You can find the originals in the references below for deeper coverage.
References
- Build Options | Vite Official Docs
- Performance | Vite Official Docs
- Environment API | Vite Official Docs
- Vite 6 Released: New Environment API | InfoQ
- Vite 6.0 Build Optimization: Reduce Build Times by 70% | Markaicode
- Vite 6: New Features and Complete Migration Guide for 2025 | Reintech
- Vite 8 Beta: The Rolldown-powered Vite | Vite Official Blog
- Mastering Vite's Chunking Strategy: Best Practices | Runebook
- Use manual chunks with Vite for dependency caching | Soledad Penadés
- What's New in ViteLand: November 2025 | VoidZero
- Vite 6/7 Cold Start Regression in Massive Module Graphs | Tech Champion
- Build Tools Performance Benchmarks | GitHub