Vite & Rollup Build Optimization: Shaping What Ships to the Browser
This topic extends JavaScript Bundle Optimization & Code Splitting to the build tool that now produces a large share of production frontends. Vite's development server serves native ES modules, but its production build is a Rollup bundle (with Rolldown increasingly replacing Rollup underneath in recent versions), and that build decides how many requests the browser makes, how much JavaScript it parses before the first interaction, and how well the output caches across deploys.
Vite's defaults are good — tree-shaking, route-level splitting from dynamic imports, CSS code splitting, modulepreload for dependencies — but defaults are tuned for the average app. A dashboard with a 600KB charting library, a storefront with dozens of routes, or a design system shared across micro-frontends each needs different chunking. The cost of getting it wrong shows up directly in Core Web Vitals: a vendor chunk that changes on every deploy forces returning users to re-download it, harming LCP; a route chunk that pulls in a heavy library on the landing page inflates parse time and Total Blocking Time, harming INP.
The Metric Degradation This Topic Addresses
Build configuration problems manifest as three kinds of field regression:
- LCP regressions after deploys. Returning visitors usually have most JavaScript cached. If chunk hashes change whenever any dependency changes — because one large vendor chunk contains everything — every deploy turns returning visitors into first-time visitors for JavaScript. Pages whose LCP depends on client rendering get measurably slower for days after each release.
- INP and TBT regressions from over-eager loading. A chunking strategy that groups rarely used libraries with commonly used ones makes every page parse and evaluate code it does not run. On mid-tier phones, parse and compile cost roughly 1ms per 10–20KB of minified JavaScript before any execution.
- Waterfalls from under-preloaded imports. A route chunk that imports another chunk that imports a third creates a request chain; without
modulepreloadhints for the whole chain, each level costs a round trip.
The thresholds are the familiar ones: LCP under 2.5s, INP under 200ms, and a lab Total Blocking Time under roughly 200ms on a mid-tier mobile profile. The build is not the only factor, but it sets the floor for all three.
Prerequisites
- Vite 5 or later (examples use Vite 6/7 option names;
build.rollupOptionsremains the escape hatch for Rollup output settings). - A bundle visualiser:
rollup-plugin-visualizer(orvite-bundle-visualizer), configured to emit a treemap with gzip and brotli sizes. - A production-like preview:
vite build && vite previewbehind a throttled DevTools profile; never draw conclusions from the dev server, which serves unbundled modules. - Known browser targets. Vite's
build.targetdefaults to a modern baseline; confirm it matches your analytics before tuning polyfills.
1. Environment Setup: Make the Build Observable
Emit a stats treemap and a manifest on every build so changes in chunking are visible in pull requests.
// vite.config.js
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
build: {
manifest: true, // maps entries to chunks — useful for SSR preloads and CI checks
sourcemap: 'hidden', // source maps for analysis without exposing them via comments
reportCompressedSize: true,
},
plugins: [visualizer({ filename: 'dist/stats.html', gzipSize: true, brotliSize: true, template: 'treemap' })],
});
// trade-off: hidden source maps are still emitted to dist/. Exclude *.map from
// the public deploy (or upload them only to your error tracker) so you do not
// publish your original source.
2. Capture a Baseline per Route
For each key route, record which chunks load on first navigation, their sizes, and the request chain depth. The manifest makes this scriptable: walk from each entry through imports and dynamicImports.
// scripts/route-chunks.mjs — list the static import closure of each entry.
import manifest from '../dist/.vite/manifest.json' with { type: 'json' };
function closure(key, seen = new Set()) {
if (seen.has(key)) return seen;
seen.add(key);
for (const dep of manifest[key].imports ?? []) closure(dep, seen);
return seen;
}
for (const [key, chunk] of Object.entries(manifest)) {
if (!chunk.isEntry && !chunk.isDynamicEntry) continue;
const files = [...closure(key)].map((k) => manifest[k].file);
console.log(key, files.length, 'chunks');
}
// trade-off: the manifest knows static imports but not runtime behaviour. A
// dynamic import triggered on load still costs a request; confirm with the
// Network panel for routes that matter.
3. Isolate the Bottleneck
Open the treemap and look for three patterns: a large library inside the entry or a shared chunk that only one route uses; the same library appearing in several chunks (duplication); and a vendor chunk that contains both stable libraries and frequently updated internal packages. Each points to a different fix in the guides below.
4. Apply the Fix
The highest-leverage changes, in typical order of impact:
- Move heavy, route-specific libraries behind dynamic imports at the point of use — a chart library imported only when the chart mounts.
- Define
manualChunksthat group by update frequency, so framework and stable vendor code live in long-cached chunks separate from your frequently changing application code. Configuring manualChunks in Vite shows a strategy that avoids over-splitting. - Verify modulepreload coverage for dynamic routes so chains load in parallel; see controlling Vite modulepreload for dynamic imports.
- Set
build.targetto the browsers you actually support, avoiding unnecessary syntax down-levelling — covered in targeting baseline browsers with esbuild and SWC.
Deconstructing JavaScript Cost on the Critical Path
From the browser's perspective, each chunk on the critical path costs four phases, each with its own lever in the build:
| Phase | What drives it | Build lever | Rough budget (mid-tier mobile) |
|---|---|---|---|
| Request + download | bytes, chain depth, cache hit | chunking, preload, hashing | < 300ms for initial JS |
| Parse + compile | uncompressed size, syntax | target, minifier, splitting | < 100ms |
| Evaluate (module init) | top-level side effects | sideEffects, lazy init | < 50ms per chunk |
| Execute (render/hydrate) | application work | route splitting, islands | < 200ms total |
Compressed transfer size governs the first phase only. The others scale with uncompressed code size and with what that code does when evaluated, which is why a budget expressed purely in gzip kilobytes under-predicts INP problems — the argument made in why compressed size is the wrong budget.
Advanced Diagnostics and Edge Cases
CSS code splitting and FOUC. Vite splits CSS per async chunk and injects it when the chunk loads. If a route chunk's CSS arrives after its JavaScript renders, users see a flash of unstyled content and layout shift. Vite inserts the CSS before resolving the dynamic import, but custom loaders that bypass import() can break that ordering.
Circular chunks. manualChunks functions that assign modules by path can create chunks that import each other, which Rollup resolves but which can cause evaluation-order bugs and extra requests. The visualiser's "network" view helps spot cycles.
Shared dependencies between SSR and client. In SSR setups, the server bundle and client bundle are built separately; a module marked noExternal for SSR may end up duplicated or differently split. Check both manifests.
Rolldown differences. As Vite moves to the Rust-based Rolldown bundler, chunking heuristics and advancedChunks options differ from Rollup's manualChunks. Re-run your baseline after upgrading and compare chunk lists rather than assuming identical output.
Legacy plugin output. @vitejs/plugin-legacy emits a second set of chunks with polyfills for older browsers. It is served only to browsers that need it, but doubles build time and can confuse size reports that sum all output.
Validation and Budgeting
Lock in chunk sizes per entry in CI. A small script over the manifest can fail the build when any route's initial closure grows beyond its budget, and enforcing bundle budgets with size-limit provides a ready-made tool.
// scripts/check-route-budgets.mjs
import { statSync } from 'node:fs';
import manifest from '../dist/.vite/manifest.json' with { type: 'json' };
const budgets = { 'src/main.ts': 180_000, 'src/routes/Checkout.vue': 90_000 }; // bytes, uncompressed
for (const [entry, limit] of Object.entries(budgets)) {
const seen = new Set(); const walk = (k) => { if (seen.has(k)) return; seen.add(k); (manifest[k].imports ?? []).forEach(walk); };
walk(entry);
const bytes = [...seen].reduce((n, k) => n + statSync(`dist/${manifest[k].file}`).size, 0);
if (bytes > limit) { console.error(`${entry}: ${bytes} > ${limit}`); process.exitCode = 1; }
}
// trade-off: uncompressed byte budgets track parse cost well but ignore what
// the code does at runtime. Pair them with a Lighthouse TBT assertion on the
// same routes.
Track the hash stability of vendor chunks across releases as well: a script that compares chunk filenames between the previous and current deploy shows what share of JavaScript returning users must re-download.
Build Targets, Minification and Syntax Cost
build.target controls which JavaScript syntax esbuild down-levels. Vite's default modern target leaves classes, optional chaining, async/await and other ES2020+ syntax intact, which keeps output small and fast to parse. Setting a lower target (es2015, or matching a broad Browserslist query) forces transformations that inflate code — async functions become generator-based state machines, classes become functions with helper calls — typically adding 10–20% to bundle size and measurable parse time. Choose the target from real analytics: if 99.5% of your traffic runs browsers from the last three years, target them and serve a legacy build (or nothing) to the rest.
Minification is the other lever. esbuild minifies quickly and well; enabling build.minify: 'terser' with multiple passes can save a few percent more at a large build-time cost. More impactful are the settings that let the minifier remove code: define replacements for feature flags and environment checks (so if (import.meta.env.DEV) branches disappear entirely), and esbuild.drop: ['console', 'debugger'] for production builds, which removes logging calls whose arguments may themselves be expensive to evaluate.
// vite.config.js — syntax and dead-code settings for production.
export default defineConfig(({ mode }) => ({
build: { target: ['chrome111', 'edge111', 'firefox114', 'safari16.4'], cssMinify: 'lightningcss' },
esbuild: mode === 'production' ? { drop: ['console', 'debugger'], legalComments: 'none' } : {},
define: { __ENABLE_DEVTOOLS_PANEL__: JSON.stringify(mode !== 'production') },
}));
// trade-off: dropping console removes ALL console calls, including console.error
// you may rely on for production diagnostics. Use pure: ['console.log'] instead
// to remove only the calls you consider noise.
SSR Manifests and Server-Emitted Hints
In server-rendered setups — Nuxt, SvelteKit, Vike, or a custom Express + Vite integration — the client build's manifest is the bridge between what the server renders and what the browser should fetch. The server knows the route; the manifest knows the route's chunks and CSS. Emitting <link rel="modulepreload"> for those chunks and <link rel="stylesheet"> for route CSS in the HTML head lets the browser start them during HTML parsing instead of after the entry executes. Frameworks do this automatically; custom integrations often forget, producing a waterfall where the route chunk is discovered only after hydration starts. If your server streams HTML, emit these hints in the first flush so they are not delayed by slow data — the subject of flushing the document head early.
A Release Checklist for Build Changes
Build configuration changes are deceptively risky: a one-line manualChunks edit can reshuffle every hash and change evaluation order. Treat them like code changes with their own checks:
- Compare the treemap before and after, per route, and record initial-JS deltas in the pull request.
- Confirm no package now appears in more than one chunk.
- Run the application's end-to-end tests against the production build, not the dev server, to catch evaluation-order bugs.
- Measure LCP and TBT for two key routes in a throttled lab run.
- After deploy, watch returning-visitor LCP and error rates for chunk load failures for a few days — new chunk names mean old tabs may request files that no longer exist, covered in handling chunk load errors after a deploy.
FAQ
Should I disable Vite's automatic chunk splitting and do everything manually?
Rarely. The automatic strategy handles dynamic-import boundaries and shared modules well. manualChunks is best used narrowly — to group stable vendor code for caching and to keep a handful of heavy libraries together — while leaving route splitting to the defaults. Fully manual chunking tends to rot as the codebase changes.
Is esbuild or terser the better minifier for Vite builds?
esbuild (the default) is dramatically faster and produces output within a few percent of terser's size for most code. Terser can squeeze slightly more, which matters only for very large bundles. The comparison is covered in esbuild vs Terser for production minification.
Do these settings apply to Nuxt, SvelteKit and Astro?
Yes — all three build on Vite, and expose vite configuration (Nuxt via vite in nuxt.config, SvelteKit and Astro via their config files). Framework defaults sometimes override chunking, so inspect the output treemap rather than assuming your Vite options were applied.
How often should chunking be revisited?
Whenever the dependency list changes materially and at least quarterly. Chunking that was right for twenty dependencies drifts as libraries are added, upgraded or replaced. A scheduled review of the treemap and the per-route budgets keeps the configuration aligned with the code it describes, and is far cheaper than discovering in field data that the landing route has quietly grown by 150KB.
Does Vite's dev server performance matter for Core Web Vitals?
No. The dev server serves unbundled modules with on-demand transforms, which is optimised for fast feedback while editing, not for page-load performance. Users only ever receive the production build. Measure, budget and debug performance exclusively against vite build output served by vite preview or your real hosting; dev-server timings are misleading in both directions.
Guides in This Topic
- Configuring manualChunks in Vite — group vendor code for caching without over-splitting.
- Analyzing Vite bundles with rollup-plugin-visualizer — read the treemap and act on it.
- Controlling Vite modulepreload for dynamic imports — flatten request chains without over-preloading.
- Building tree-shakeable libraries with Vite library mode — ship packages consumers can trim.
Related
- Vite vs Webpack bundle splitting performance — how the two bundlers' defaults compare.
- Dynamic imports and route-based splitting — the splitting points Vite turns into chunks.
- JavaScript performance budgets — enforcing the results in CI.
- Polyfills and differential serving — choosing build targets and polyfills.