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.

Vite production build pipeline Stages of a Vite production build from module graph to emitted, hashed chunks with preload metadata. Vite production build pipeline Module graph entries + dynamic imports Tree-shake drop unused exports Chunk automatic + manualChunks Minify esbuild / terser Emit hashes + modulepreload

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 modulepreload hints 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.

Initial JavaScript by chunking strategy (same app) Bar chart of JavaScript loaded on the landing route of one application under four chunking strategies. Initial JavaScript by chunking strategy (same app) Single bundle (no splitting) 612KB Vite defaults 248KB + vendor split by update frequency 251KB + heavy libs lazy per route 164KB The vendor split barely changes first-load size but sharply improves cache reuse across deploys; lazy libraries cut the landing route.

Prerequisites

  • Vite 5 or later (examples use Vite 6/7 option names; build.rollupOptions remains the escape hatch for Rollup output settings).
  • A bundle visualiser: rollup-plugin-visualizer (or vite-bundle-visualizer), configured to emit a treemap with gzip and brotli sizes.
  • A production-like preview: vite build && vite preview behind a throttled DevTools profile; never draw conclusions from the dev server, which serves unbundled modules.
  • Known browser targets. Vite's build.target defaults 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.

javascript
// 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.

javascript
// 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.

Treemap pattern to fix Mapping of patterns visible in a Vite bundle treemap to the configuration change that addresses each. Treemap pattern to fix Pattern in treemap Cost Fix Heavy lib in entry chunk parse on every route Dynamic import at the usage site Same lib in several chunks duplicate bytes manualChunks group or dedupe Vendor mixes stable and volatile code cache busted every deploy Split vendor by update frequency Deep import chains round-trip waterfall Check modulepreload coverage

4. Apply the Fix

The highest-leverage changes, in typical order of impact:

  1. Move heavy, route-specific libraries behind dynamic imports at the point of use — a chart library imported only when the chart mounts.
  2. Define manualChunks that 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.
  3. Verify modulepreload coverage for dynamic routes so chains load in parallel; see controlling Vite modulepreload for dynamic imports.
  4. Set build.target to 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:

PhaseWhat drives itBuild leverRough budget (mid-tier mobile)
Request + downloadbytes, chain depth, cache hitchunking, preload, hashing< 300ms for initial JS
Parse + compileuncompressed size, syntaxtarget, minifier, splitting< 100ms
Evaluate (module init)top-level side effectssideEffects, lazy init< 50ms per chunk
Execute (render/hydrate)application workroute splitting, islands< 200ms total

Critical-path cost of an initial Vite bundle on a mid-tier phone Timeline of downloading, parsing, evaluating and executing the initial JavaScript of a Vite app, with a 50 millisecond long-task marker. Critical-path cost of an initial Vite bundle on a mid-tier phone Network download 248KB Main thread parse + compile eval render + hydrate 0ms 100ms 200ms 300ms 400ms 500ms 600ms 700ms 800ms 900ms

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.

javascript
// 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.

JavaScript re-downloaded by returning users after a typical deploy Bar chart of the share of JavaScript bytes whose hashes change after a routine deploy, under three chunking strategies. JavaScript re-downloaded by returning users after a typical deploy One vendor chunk with internal packages 78% Vite defaults 41% Vendor split by update frequency 12%

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.

javascript
// 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:

  1. Compare the treemap before and after, per route, and record initial-JS deltas in the pull request.
  2. Confirm no package now appears in more than one chunk.
  3. Run the application's end-to-end tests against the production build, not the dev server, to catch evaluation-order bugs.
  4. Measure LCP and TBT for two key routes in a throttled lab run.
  5. 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