Rollup manualChunks and Stable Chunk Hashes

output.manualChunks decides which modules live in the same output file, and that decision — not the hash algorithm — is what determines how much of your bundle a returning visitor has to re-download after a release. This page shows how the object and function forms differ, why the popular “everything in node_modules goes to vendor” recipe destroys cache hit rates, and how to measure chunk churn by building twice across a realistic edit.

The symptom

A user visits your site on Monday and caches 512 kB of JavaScript. On Tuesday you bump a logging library from 2.1.4 to 2.1.5 — a two-line patch that touches nothing the user can see. On Wednesday the same user returns and downloads 512 kB again.

Nothing is wrong with the hash. Rollup hashed exactly what changed: the single output file that happens to contain both the logging library and every other dependency. The bug is in the chunk boundary, and the chunk boundary is manualChunks.

Hash stability is a property of chunk membership. A chunk keeps its hash when — and only when — every module inside it, every module it statically imports, and the rendered import statements between them are byte-identical to the previous build. Grouping modules with different change frequencies into one chunk guarantees the whole group churns at the rate of its fastest-moving member.

Object form vs function form

Rollup 4 accepts two shapes for output.manualChunks.

The object form maps a chunk name to a list of module IDs. Rollup resolves each entry once, before bundling, and pulls those modules plus everything they exclusively depend on into the named chunk:

manualChunks: {
  'vendor-react': ['react', 'react-dom'],
  'vendor-charts': ['chart.js']
}

The function form is called once per module with the resolved absolute module ID and returns a chunk name, or undefined to let Rollup’s automatic splitting decide:

manualChunks(id) {
  if (id.includes('/node_modules/react')) return 'vendor-react';
  return undefined;
}
manualChunks: object form vs function form Two panels compare the object form, which resolves a fixed name-to-module map before the build, with the function form, which runs for every module id and can inspect the module graph. Each panel ends with a verdict on which workloads it suits. Object form Function form fixed name to module list runs per resolved module id Resolved once, before bundling Chunk names are explicit Blind to the module graph Best for a small fixed vendor set Called for every module id Can call getModuleInfo(id) Easy to over-group vendors Best for many packages
The object form fixes chunk membership up front; the function form computes it per module and can inspect the graph, at the cost of being easy to over-group.

The forms are not interchangeable. The object form only accepts modules that are reachable from an entry point, and a name that resolves to nothing is silently ignored — no warning, no chunk. The function form sees every module including virtual ones injected by plugins, so a careless id.includes('node_modules') test will also capture plugin helper modules whose IDs contain that substring only by accident.

Prefer the object form when you have a handful of dependencies you want pinned to named chunks forever. Prefer the function form when the dependency list is long enough that maintaining it by hand would drift out of date.

Why the node_modules recipe goes wrong

The recipe copied into most configs is three lines:

manualChunks(id) {
  if (id.includes('node_modules')) return 'vendor';
}

It produces exactly one vendor chunk. That chunk contains your UI framework, your date library, your HTTP client, your polyfills, and the transitive dependency you have never heard of that ships a patch every fortnight. Its hash changes when any one of them changes, so the effective invalidation rate of the chunk is the sum of the release rates of everything inside it.

Invalidated bytes per chunking strategy Horizontal bars compare how many kilobytes a returning visitor must re-download after a single patch-level dependency bump: 512 kB with no manual chunking, 384 kB with a single vendor chunk, and 48 kB with per-package chunks. Re-downloaded after one patch-level dependency bump No manualChunks 512 kB — the whole bundle Single vendor chunk 384 kB of vendor code 2 chunks Per-package chunks 48 kB 11 chunks, 3 extra requests The same 512 kB ships in every case — only the chunk boundary changes what gets invalidated.
Chunk boundaries, not hash length, decide how much cached bytes a single dependency bump throws away.

The opposite extreme is not free either. Splitting per package on a large dependency tree produces hundreds of chunks, and every one of them is a separate request, a separate cache entry, and a separate line in the asset manifest. Beyond about a dozen chunks, HTTP/2 multiplexing stops hiding the per-request overhead and compression ratios drop because each chunk is deflated against its own dictionary.

The useful middle ground is to isolate the packages that are both large and slow-moving — a UI framework, a charting library, a rich-text editor — and let everything else share one bucket. The trade-offs of splitting vendor code away from application code are covered in more depth in the guide on hashing vendor bundles versus app bundles.

Strategy comparison

Strategy Chunk count (typical SPA) Hash stability after a dep bump First-load requests Repeat-visit cache hit
No manualChunks 1–3 Worst — entry rehashes on any change 1–3 Near 0% after any release
Single vendor chunk 2–4 Poor — one bump rehashes all vendors 2–4 20–40%
Framework split (vendor-react + vendor) 3–6 Good for the framework, poor for the rest 3–6 55–70%
Curated isolation (large, slow packages named) 6–12 Good — churn confined to the bumped package 6–12 80–90%
Per-package chunks 60–300 Best per chunk, worst in aggregate 60+ 90%+ but request-bound

Cache-hit figures assume a fortnightly release cadence and a one-year immutable lifetime on hashed paths. The numbers move with your dependency count, but the ordering does not.

Silent membership moves

Two Rollup behaviours move modules between chunks without you asking, and both show up as an unexplained hash change on a chunk you did not touch.

Shared-module hoisting. If a module is reachable from two different manualChunks groups, Rollup cannot place it in either without duplicating it. It hoists the module into a separate generated chunk that both groups import. That generated chunk is named by output.chunkFileNames, not by you, and editing the shared module rehashes a file that appears in no part of your config.

Shared-module hoisting On the left, two declared groups vendor-ui and vendor-data both depend on date-fns. On the right, Rollup emits vendor-ui and vendor-data plus a third generated chunk containing the hoisted date-fns module, which both emitted chunks import. What you declare What Rollup emits vendor-ui react, mui vendor-data axios, swr date-fns used by both vendor-ui-a1b2c3d4 stable vendor-data-7e2d stable chunk-e91f0c7d.js hoisted, unnamed A module reachable from two groups is hoisted into a generated chunk you never named. Editing it rehashes that chunk, and the two parents rehash because their import paths change.
Rollup hoists a doubly-reachable module into its own generated chunk; a change there cascades into both parents through the rewritten import specifiers.

Static-plus-dynamic import merging. When a module is both import()ed and statically imported somewhere else, Rollup logs is dynamically imported by X but also statically imported by Y and folds the module into the static chunk. Your lazy route stops being lazy, the static chunk grows, and its hash now tracks changes in code you believed was split out.

Circular dependencies compound both. A cycle that crosses a manualChunks boundary forces Rollup to keep the whole cycle in one chunk, because ES module semantics require the cycle members to be initialised together. Run the build with --silent=false and treat every CIRCULAR_DEPENDENCY warning that names two different chunk groups as a hash-stability defect.

One more knob moves modules quietly: output.experimentalMinChunkSize. It defaults to 1 in Rollup 4, which only merges effectively empty chunks. Raising it — a common tip for reducing request counts — lets Rollup merge any chunk under the threshold into a sibling, which is precisely the silent membership move you are trying to eliminate. Leave it at the default when hash stability matters.

chunkFileNames and hashCharacters

manualChunks supplies the [name] token that output.chunkFileNames interpolates. Returning 'vendor-react' gives you assets/vendor-react-a1b2c3d4.js; returning nothing leaves Rollup’s generated name, which is derived from the first module in the chunk and looks like assets/index-a1b2c3d4.js. Two different generated chunks can both want index, at which point Rollup disambiguates with a numeric suffix that depends on module ordering — a genuine source of non-deterministic output if a plugin ever iterates the filesystem in directory order.

Naming every chunk you care about removes the ambiguity. The function form of chunkFileNames receives the chunk info object, so you can route named and generated chunks to different directories:

chunkFileNames(chunkInfo) {
  return chunkInfo.name.startsWith('vendor-')
    ? 'assets/vendor/[name]-[hash:8].js'
    : 'assets/app/[name]-[hash:8].js';
}

output.hashCharacters accepts 'base64' (default), 'base36', and 'hex'. It changes the alphabet the [hash] token renders in, not the hash input, so switching it rewrites every filename exactly once and then behaves identically. Use 'hex' when you want to compare a filename against sha256sum output during an incident; use 'base36' when assets land in a case-insensitive object store where two base64 hashes differing only in case would collide.

A complete configuration

This config isolates the packages that are large and slow-moving, buckets everything else, and emits a chunk map for the verification step below.

// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import terser from '@rollup/plugin-terser';

// Packages large enough and stable enough to deserve their own cache entry.
const ISOLATED = new Set(['react', 'react-dom', 'scheduler', 'chart.js', 'date-fns']);

function packageNameOf(id) {
  const marker = '/node_modules/';
  const at = id.lastIndexOf(marker);
  if (at === -1) return null;
  const parts = id.slice(at + marker.length).split('/');
  return parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0];
}

function chunkMapPlugin({ fileName = 'chunk-map.json' } = {}) {
  return {
    name: 'chunk-map',
    generateBundle(outputOptions, bundle) {
      const map = {};
      const root = `${process.cwd()}/`;
      for (const [outputFileName, chunk] of Object.entries(bundle)) {
        if (chunk.type !== 'chunk') continue;
        map[chunk.name] = {
          file: outputFileName,
          modules: Object.keys(chunk.modules)
            .map((id) => id.replace(root, ''))
            .sort()
        };
      }
      this.emitFile({
        type: 'asset',
        fileName,
        source: JSON.stringify(map, null, 2)
      });
    }
  };
}

export default {
  input: { app: 'src/main.js', admin: 'src/admin.js' },
  output: {
    dir: 'dist',
    format: 'es',
    hashCharacters: 'hex',
    entryFileNames: 'assets/[name]-[hash:8].js',
    chunkFileNames: 'assets/[name]-[hash:8].js',
    assetFileNames: 'assets/[name]-[hash:8][extname]',
    manualChunks(id) {
      const pkg = packageNameOf(id);
      if (!pkg) return undefined;
      if (ISOLATED.has(pkg)) return `vendor-${pkg.replace('/', '-').replace('@', '')}`;
      return 'vendor-common';
    }
  },
  plugins: [
    resolve({ browser: true }),
    commonjs(),
    terser({ format: { comments: false } }),
    chunkMapPlugin()
  ]
};

Eight hex characters are enough for a build in this size range; move to [hash:12] once a single build emits more than a couple of thousand chunks.

The same logic through the Vite 5 API, where build.rollupOptions.output is passed straight through to Rollup:

// vite.config.js
import { defineConfig } from 'vite';

const ISOLATED = new Set(['react', 'react-dom', 'scheduler', 'chart.js', 'date-fns']);

function packageNameOf(id) {
  const marker = '/node_modules/';
  const at = id.lastIndexOf(marker);
  if (at === -1) return null;
  const parts = id.slice(at + marker.length).split('/');
  return parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0];
}

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        hashCharacters: 'hex',
        entryFileNames: 'assets/[name]-[hash:8].js',
        chunkFileNames: 'assets/[name]-[hash:8].js',
        assetFileNames: 'assets/[name]-[hash:8][extname]',
        manualChunks(id) {
          const pkg = packageNameOf(id);
          if (!pkg) return undefined;
          if (ISOLATED.has(pkg)) return `vendor-${pkg.replace('/', '-').replace('@', '')}`;
          return 'vendor-common';
        }
      }
    }
  }
});

Vite pre-bundles dependencies in development but uses the real Rollup graph for vite build, so the function sees the same absolute node_modules paths in production. Other options Vite layers on top of Rollup are described in the Vite asset pipeline configuration guide.

Verification: diff two builds’ chunk maps

Chunking quality is measurable. Build, make a realistic edit, build again, and compare which chunks kept their filenames.

# 1. Baseline build
rm -rf dist && npx rollup -c
cp dist/chunk-map.json /tmp/chunk-map-before.json

# 2. A realistic edit: bump one dependency, leave application code alone
npm install date-fns@3.6.0

# 3. Rebuild
rm -rf dist && npx rollup -c

# 4. Report which chunks rehashed and which changed membership
node -e "
const fs = require('fs');
const before = JSON.parse(fs.readFileSync('/tmp/chunk-map-before.json', 'utf8'));
const after = JSON.parse(fs.readFileSync('dist/chunk-map.json', 'utf8'));
const names = new Set([...Object.keys(before), ...Object.keys(after)]);
let churn = 0;
for (const name of [...names].sort()) {
  const b = before[name];
  const a = after[name];
  if (!b) { console.log('ADDED    ', name, a.file); churn++; continue; }
  if (!a) { console.log('REMOVED  ', name, b.file); churn++; continue; }
  const moved = JSON.stringify(b.modules) !== JSON.stringify(a.modules);
  const rehashed = b.file !== a.file;
  if (rehashed) churn++;
  console.log(
    rehashed ? 'REHASHED ' : 'STABLE   ',
    name.padEnd(20),
    moved ? 'membership changed' : ''
  );
}
console.log('churned chunks:', churn, 'of', names.size);
"

A healthy result after bumping one dependency is exactly one REHASHED line — the chunk holding that package — and no membership changed annotations anywhere. Every extra rehash is cache you threw away, and every membership changed line is a module that moved between chunks, which is the hoisting behaviour described above.

Run the same script with step 2 replaced by a one-line edit to src/main.js. Only the app entry chunk should rehash; if a vendor chunk moves, your application code has leaked into a vendor group through a re-export barrel file.

When to reconsider

Curated chunking is not always the right answer.

If your total JavaScript payload is under about 150 kB compressed, a single chunk is faster than any split: you save the request round-trips and the compressor gets one large dictionary instead of several small ones. The cache-hit argument only pays off when the bytes you preserve exceed the bytes the extra requests cost.

If your dependency tree is dominated by one enormous package that also releases weekly, isolating it does nothing — the chunk churns anyway. Fix the dependency cadence, or accept the churn and shrink the chunk by dropping to a smaller library.

If you deploy behind a CDN that already serves everything with a long immutable lifetime and your release cadence is monthly rather than daily, the difference between “good” and “best” chunking is a few dozen kilobytes per user per month. Spend the effort on the entry chunk instead, which every visitor downloads on every release regardless of how the vendors are split.

FAQ

Does returning undefined from manualChunks disable splitting for that module?

No. It hands the module back to Rollup’s automatic chunking, which places it in whichever generated chunk keeps the graph correct without duplication. Returning undefined is the correct default for application modules.

Why did my chunk hash change when I only reordered imports in an unrelated file?

Reordering imports can change the order in which Rollup renders modules inside a chunk, and the chunk hash covers the rendered text. It can also change which module Rollup picks as the chunk’s derived name. Name the chunk explicitly and the name stops moving; the render order is stable as long as the module graph is.

Can I use getModuleInfo inside the function form?

Yes — the second argument is an object exposing getModuleInfo(id), which returns importers, dynamic importers, and whether the module is an entry. Use it to keep a package out of a vendor chunk when its only importer is a lazy route.