Choosing a Rollup Chunking Strategy: Entry Points, manualChunks, or preserveModules
A decision guide to Rollup code splitting: when to use entry-point chunks, a manualChunks function, or preserveModules, with working configs and validation steps.
18 Jul 2026, 22:46 UTC

The decision most Rollup users face is not whether to split code — Rollup splits automatically whenever it sees multiple entry points or dynamic import() calls — but how to split it. The right answer depends on one constraint above all: are you building an application (bundled for browsers, cached long-term) or a library (consumed by other bundlers)? Picking the wrong strategy either ships a monolithic vendor file that busts its cache on every dependency bump, or emits a pre-bundled library that downstream tools cannot tree-shake.
The three supported options
Rollup (v3 and later; code splitting is always on when multiple entries or dynamic imports exist — the old experimentalCodeSplitting flag is gone) gives you three practical strategies:
| Strategy | How it works | Best for | Main trade-off |
|---|---|---|---|
| Entry-point splitting | Pass an object or array of inputs; Rollup emits one chunk per entry plus shared chunks for common modules | Multi-page apps, worker + main bundles | Shared dependencies land in an automatically generated common chunk you do not name or control |
manualChunks function | Return a chunk name per module ID; modules with the same name are grouped | Applications needing cache-friendly vendor splitting | Requires maintenance as dependencies change; easy to create circular chunk imports if careless |
preserveModules | Emit one output file per source module, mirroring the source tree | Libraries | No bundling of your own code; must be combined with external for dependencies |
Applications: prefer a manualChunks function over a single vendor blob
The zero-config pattern — manualChunks: (id) => id.includes('node_modules') ? 'vendor' : undefined — works, but it produces one large vendor chunk whose content hash changes whenever any dependency updates. If you ship weekly and bump a patch version of one package, every user re-downloads React, lodash, and everything else.
A function that splits large, independently versioned libraries into their own chunks gives you finer cache granularity:
// rollup.config.js — application build
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
export default {
input: 'src/main.js',
plugins: [resolve(), commonjs()],
output: {
dir: 'dist',
format: 'es',
entryFileNames: '[name]-[hash].js',
chunkFileNames: '[name]-[hash].js',
manualChunks(id) {
if (!id.includes('node_modules')) return;
if (id.includes('react') || id.includes('scheduler')) return 'react';
if (id.includes('lodash')) return 'lodash';
return 'vendor'; // everything else from node_modules
},
},
};Run npx rollup -c from the project root (no special permissions needed). Then inspect dist/: you should see main-[hash].js importing from react-[hash].js, lodash-[hash].js, and vendor-[hash].js. The entry chunk should contain only your application code plus Rollup's small inter-chunk import glue — grep it for a distinctive lodash function name to confirm no vendor code leaked in.
Two cautions with this approach. First, dynamic import() expressions always create their own chunk regardless of manualChunks; you can influence naming via chunkFileNames but you cannot merge them back without output.inlineDynamicImports: true, which disables splitting entirely. Second, a dynamic import of a bare specifier like import('lodash') is treated as external by default and will fail at runtime unless it is also statically reachable or explicitly chunked — prefer relative dynamic imports for lazy routes.
Libraries: do not split at all
For a library, chunking is an anti-feature. Consumers run their own bundler and want to tree-shake your exports. Emit an unbundled module tree instead:
// rollup.config.js — library build
export default {
input: 'src/index.js',
external: ['react', 'react-dom'], // peer dependencies, never bundled
output: {
dir: 'dist/esm',
format: 'es',
preserveModules: true,
preserveModulesRoot: 'src',
},
};After building, dist/esm/ mirrors src/ file-for-file, and your package.json exports map can point at it. Note that preserveModules and manualChunks are mutually exclusive — combining them is a config error. Also make sure anything in external is declared as a peer dependency, or consumers will hit missing-module errors.
Reducing what ends up in any chunk
Chunking strategy decides placement; tree-shaking decides size. Rollup drops unused exports before chunk assignment, but only when it can prove modules are side-effect-free. Two levers matter: dependencies that declare "sideEffects": false in their package.json are shaken aggressively, and for your own code you can set treeshake.moduleSideEffects: false if no module relies on import-time side effects. Verify the effect by building before and after and comparing chunk sizes — a vendor chunk that shrinks substantially confirms dead exports were being retained.
Validating the result
Do not trust the config; inspect the output. The practical checks:
- Build with
npx rollup -cand listdist/. Confirm the expected chunk names and that entry chunks import from vendor chunks rather than containing dependency source. - Add
rollup-plugin-visualizerto the config, rebuild, and open the generatedstats.html. Look for duplicated modules across chunks — duplication usually means a module is reachable both statically and through a dynamic import, or amanualChunksrule is inconsistent. - Test cache behavior: bump one dependency version in
package.json, reinstall, rebuild. With per-library splitting, only that library's chunk hash should change; entry chunk hashes stay stable if your source did not change. With a monolithic vendor chunk, everything invalidates — which is exactly the behavior you are trying to avoid. - For dynamic imports, add a route-level
import('./lazy.js'), rebuild, and confirm a separatelazy-[hash].jsappears and is fetched only when the route runs (check the network panel).
Limitations to keep in mind
Since Rollup 4.0, manualChunks may be async (returning a Promise), which enables grouping logic that reads package.json — but async chunking slows builds and is rarely worth it for small dependency sets. Advanced graph-aware splitting via the getModuleInfo plugin context helper exists, but hand-rolled graph logic is fragile across Rollup minor versions; prefer the simple ID-based function unless you have measured a real problem. Finally, aggressive manual splitting can create many small chunks; on HTTP/1.1 or with many tiny lazy routes, request overhead can outweigh caching gains — verify with the visualizer and real load timing rather than assuming more chunks is better.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.