How to Configure manualChunks in Vite Without Over-Splitting

This guide is part of Vite & Rollup Build Optimization in JavaScript Bundle Optimization & Code Splitting. build.rollupOptions.output.manualChunks is the single most powerful — and most misused — chunking control in Vite. Used well, it puts slow-changing framework and vendor code into long-lived chunks that returning visitors keep in cache for months, while your frequently deployed application code changes independently. Used badly, it either crams every dependency into one giant vendor.js that changes on every release, or splits each package into its own chunk, creating hundreds of tiny requests and evaluation-order surprises.

The goal is a small number of chunks grouped by how often they change and where they are used. Framework runtime changes rarely and is used everywhere; a charting library changes rarely but is used on two routes; your design system changes weekly; application code changes daily.

Chunk groups by update frequency Layers of a well-chunked Vite build, from rarely changing framework code to frequently changing application code. Chunk groups by update frequency framework vue / react runtime + router — changes a few times a year, used on every route vendor-stable date, validation and state libraries — pinned versions, used widely Lazy heavy libs charts, editors, maps — left to dynamic imports, loaded per route App code your components and routes — changes every deploy, split by route automatically

Rapid Diagnosis

  • Diff chunk filenames across two consecutive deploys. If the large vendor chunk's hash changes on most releases, cache reuse is poor.
  • Count chunks loaded on the landing route. More than roughly 15–20 JavaScript requests on first load suggests over-splitting.
  • Look for a giant vendor chunk in the treemap. A single chunk with everything from node_modules — including libraries only one route needs — inflates every page.
  • Check for duplicated packages across chunks; poorly designed manual chunks can force Rollup to duplicate shared modules.

Root Cause Analysis

1. "All node_modules into vendor". The most common snippet online returns 'vendor' for every module in node_modules. It defeats route splitting for dependencies: a library imported dynamically by one route is pulled into the shared vendor chunk, which every route loads.

2. One chunk per package. Returning the package name for every module creates a chunk per dependency. HTTP/2 makes many requests cheaper, but each still costs scheduling, headers and per-module evaluation overhead, and dozens of tiny chunks compress worse than a few medium ones.

3. Internal packages in vendor. Monorepo packages resolved through node_modules (workspaces) land in vendor chunks and change on every deploy, busting the cache for genuinely stable third-party code.

4. Overriding dynamic-import boundaries. Assigning a module that is only used behind import() to an eagerly loaded manual chunk silently moves it onto the critical path.

Landing-route requests and cache reuse by manualChunks strategy Bar chart comparing JavaScript requests on first load for three manualChunks strategies on the same application. Landing-route requests and cache reuse by manualChunks strategy All node_modules → vendor 4 req One chunk per package 46 req Grouped by update frequency 9 req The single vendor chunk has few requests but is re-downloaded on most deploys; per-package chunks fragment the load.

Step-by-Step Resolution

1. Group only eagerly used, stable dependencies

Name an explicit allow-list of packages that are used on most routes and change rarely. Everything else keeps Vite's default behaviour.

javascript
// vite.config.js
const FRAMEWORK = ['vue', 'vue-router', '@vue/', 'pinia'];
const STABLE = ['date-fns', 'zod', 'nanoid', '@floating-ui/'];

export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (!id.includes('node_modules')) return;               // app code: default splitting
          if (FRAMEWORK.some((p) => id.includes(`/node_modules/${p}`))) return 'framework';
          if (STABLE.some((p) => id.includes(`/node_modules/${p}`))) return 'vendor-stable';
          // everything else: let Rollup decide, so lazy-only deps stay lazy
        },
      },
    },
  },
};
// trade-off: allow-lists need maintenance when you add widely used libraries.
// Review them when the treemap shows a new package in several route chunks.

Expected outcome: two long-lived shared chunks plus route chunks; heavy libraries used only by lazy routes remain in those routes' chunks.

2. Keep internal workspace packages out of vendor chunks

Detect monorepo packages (they resolve through symlinks to your repository, or have a known scope) and leave them with application code, which changes at the same cadence.

javascript
manualChunks(id) {
  if (id.includes('/packages/') || id.includes('/node_modules/@acme/')) return;   // internal: app cadence
  // ...framework / stable groups as above
}
// trade-off: if an internal design-system package is large and changes rarely,
// it may deserve its own chunk. Measure its release cadence before deciding.

Expected outcome: deploying a change to the design system no longer invalidates the framework or vendor-stable chunks.

3. Leave heavy route-specific libraries to dynamic imports

Do not assign charting, editor, map or PDF libraries to a manual chunk unless they are used eagerly. Import them where they are used:

javascript
// Chart.vue — the library loads only when this component mounts.
const { Chart } = await import('chart.js/auto');
// trade-off: the first render of the chart waits for the chunk. Prefetch it on
// hover of the tab that shows the chart, or when the route's data starts loading.

Expected outcome: the landing route stops paying for libraries it never runs.

Should this package get a manual chunk? Decision sequence for whether a dependency belongs in a named manual chunk or should be left to Vite's default splitting. Should this package get a manual chunk? Used on most routes at startup? Group it: framework or vendor-stable yes no Your own package that changes often? Leave it with application code yes no Heavy and used on one or two routes? Dynamic import at the usage site yes no Leave it to Vite's automatic splitting

4. Check evaluation order and duplication

After changing chunking, run the app and check for runtime errors caused by initialisation order (a module evaluated before a dependency it expects) and inspect the treemap for any package now appearing in more than one chunk.

5. Measure cache reuse across releases

Compare filenames between two builds of consecutive commits; the share of bytes in unchanged files is the share returning users keep.

bash
# Build two consecutive commits and compare emitted JS by name and size.
git stash -u && git checkout HEAD~1 && npm ci && npx vite build --outDir /tmp/prev && git checkout - && git stash pop
npx vite build
comm -12 <(ls /tmp/prev/assets/*.js | xargs -n1 basename | sort) <(ls dist/assets/*.js | xargs -n1 basename | sort) \
  | while read f; do stat -c %s "dist/assets/$f"; done | awk '{s+=$1} END {print s " bytes unchanged"}'
# trade-off: one pair of commits is a single sample. Track the figure over a few
# weeks of real deploys before judging the strategy.

Expected outcome: a majority of JavaScript bytes unchanged across routine deploys — commonly 80–90% with a frequency-based strategy.

Verification

Confirm three things in the built preview: the landing route loads a modest number of chunks (single digits to low teens), heavy libraries appear only when their routes are visited, and the framework and vendor-stable chunk hashes stay identical across a deploy that only changes application code. Field confirmation comes from RUM: LCP for returning visitors in the days after a deploy should no longer dip relative to the days before.

Common Mistakes with manualChunks

  • Returning a chunk name for CSS modules. Assigning .css files to JS chunk names can produce odd CSS ordering; restrict the function to .js/.ts/framework files or leave styles alone.
  • Matching too broadly. id.includes('react') matches react-dom, react-router, react-icons and any package with "react" in its name. Match on /node_modules/<package>/ boundaries.
  • Forgetting SSR builds. manualChunks in an SSR config affects the server bundle too, where chunking matters far less; configure it only for the client build if your framework allows.
  • Never revisiting the list. Dependencies change. Put a reminder in your quarterly performance review to check the treemap against the allow-lists.

FAQ

Does Vite's default already split vendor code?

Modern Vite versions do not create a single vendor chunk by default; shared dependencies end up in chunks determined by the import graph. That is good for first-load size but can produce shared chunks whose contents (and hashes) shift as the graph changes. A small, explicit framework group stabilises the most widely shared code.

What about Rolldown's advancedChunks?

Rolldown-based Vite versions offer advancedChunks with group rules (name, test, priority, size limits). The strategy in this guide translates directly: define groups for framework and stable vendor code with tests on package paths, and leave everything else to the defaults.

Is a separate chunk for polyfills worth it?

If you ship polyfills at all, yes — they change rarely and are needed only by older browsers. Better still is not shipping them to modern browsers; see removing unneeded polyfills with Browserslist.