Preserving Modules for Tree-Shaking with Rollup
A library bundled into one large file per entry point forces every consumer's bundler to parse all of it, and relies on that bundler's tree-shaking to remove what is unused. When the file contains any module-level side effect — a registry, a polyfill, a class with static initialisers — tree-shaking gives up on large parts of it and consumers ship code they never call. Rollup's preserveModules option keeps your source file structure in the output instead, so each import pulls in only the files it needs. This guide explains when that helps, how to configure it with Rollup directly and through tools built on Rollup, and the package.json settings that make it effective.
The symptom: consumers ship your whole library
The problem shows up in consumers' bundle analysers rather than in your build:
# Consumer's bundle report (rollup-plugin-visualizer / webpack-bundle-analyzer)
node_modules/@acme/ui/dist/index.js 412.8 kB (imports: Button)
A consumer who imports only Button pays for every component. Your own measurement with a one-line fixture shows the same:
echo "import { Button } from '@acme/ui'; console.log(Button)" > probe.js
npx esbuild probe.js --bundle --minify --format=esm --outfile=/tmp/out.js && ls -l /tmp/out.js
# -rw-r--r-- 1 dev dev 398112 /tmp/out.js
Why single-file bundles tree-shake poorly
Tree-shaking removes exports that nothing imports, but only when the bundler can prove that dropping the code has no observable effect. Within a single large module, any statement that might have a side effect — a top-level function call, a property assignment on an imported object, a class with decorators or static blocks — must be kept, along with everything it references. Across separate modules, bundlers can make a much coarser and cheaper decision: if no import reaches a module and the package declares it side-effect-free, the whole file is skipped without analysis. How bundlers make these decisions is covered in Bundling and Build Tooling for Libraries.
Configuring Rollup
// rollup.config.mjs
import typescript from '@rollup/plugin-typescript';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import pkg from './package.json' with { type: 'json' };
const external = [
...Object.keys(pkg.dependencies ?? {}),
...Object.keys(pkg.peerDependencies ?? {}),
].map((name) => new RegExp(`^${name}(/.*)?$`));
export default {
input: 'src/index.ts',
external,
plugins: [nodeResolve(), typescript({ tsconfig: './tsconfig.build.json' })],
output: [
{
dir: 'dist/esm',
format: 'es',
preserveModules: true,
preserveModulesRoot: 'src',
entryFileNames: '[name].js',
sourcemap: true,
},
{
dir: 'dist/cjs',
format: 'cjs',
preserveModules: true,
preserveModulesRoot: 'src',
entryFileNames: '[name].cjs',
exports: 'named',
sourcemap: true,
},
],
};
Key settings:
preserveModules: trueemits one output file per input module instead of merging them.preserveModulesRoot: 'src'strips thesrc/prefix so output paths mirror your source tree underdist/esm/.entryFileNamescontrols extensions, which matter for Node.js resolution of the CommonJS output.externalbuilt frompackage.json, with a regular expression that also matches subpaths, keeps dependencies out of the output. Without it, preserved modules include copies ofnode_modulesfiles underdist/esm/node_modules/…, which is almost never what you want.
Then declare side-effect freedom so consumer bundlers can skip unreached files:
{
"sideEffects": ["**/*.css", "./dist/esm/polyfills.js"],
"exports": {
".": {
"import": { "types": "./dist/esm/index.d.ts", "default": "./dist/esm/index.js" },
"require": { "types": "./dist/cjs/index.d.cts", "default": "./dist/cjs/index.cjs" }
}
}
}
The sideEffects field is covered in detail in Shrinking Published Package Size with sideEffects and files. Without it, consumer bundlers must assume every file might have side effects and include each file that is reachable through the barrel index.js, even though its exports are unused.
How a consumer bundler walks preserved modules
It helps to see the decision a consumer's bundler makes for each file, because it explains why sideEffects matters so much. When the consumer writes import { Button } from '@acme/ui', the bundler starts at your barrel file, dist/esm/index.js, which re-exports every component. For each re-export it asks two questions: is this export used, and may the module that provides it have side effects? If the export is unused and the package says the module is side-effect-free, the bundler never loads that module at all. If the package makes no claim, the bundler must load and evaluate the module's top level, because importing it might do something observable — and then everything that module imports comes along too.
This is also why barrel files are controversial: a barrel without sideEffects forces every consumer to load every module. With preserved modules and an accurate sideEffects field, the barrel costs almost nothing, because unused branches are pruned without being read.
Keeping the output reproducible
A preserved-modules build emits many files, so it is worth making the output deterministic and easy to diff between releases. Pin Rollup and its plugins exactly in devDependencies, avoid hashes in file names for library output (entryFileNames: '[name].js' rather than [name]-[hash].js), and set output.hoistTransitiveImports: false so each file imports only what it uses directly rather than hoisting shared chunks, which otherwise produces extra import statements that change whenever the module graph shifts. With those settings, a release diff of dist/ shows exactly which modules changed — a useful review signal for large libraries.
Using preserveModules through other tools
Several tools expose the same Rollup option:
- Vite library mode: set
build.rollupOptions.output.preserveModulesandpreserveModulesRoot, as shown in Building a Library with Vite Library Mode. - tsup (esbuild-based) has no direct equivalent, but passing every source file as an entry with
bundle: falseproduces a similar one-file-per-module layout. - Plain
tscalready emits one file per module. For many libraries without CSS or asset handling needs, compiling withtscis the simplest way to get a preserved-modules layout; add a bundler only when you need what it offers.
Trade-offs
Preserved modules are not free. The output has many more files, which increases install time slightly and can slow down consumers whose tooling resolves each file separately in development (for example, Vite's dev server before dependency pre-bundling). Internal module boundaries become visible in dist/, so deep imports into your file structure are possible unless your exports map prevents them — which it should, by listing only public entry points. And module-level directives such as "use client" survive only because each component is its own file; a plugin that preserves directives is still needed, because Rollup strips unknown directives by default.
Worked example: a charting library with one heavy module
A UI library includes a chart component that depends on a 150 kB charting engine. Applications importing only buttons and inputs still download the chart engine, because the library is bundled into one file and the chart module registers itself with a global theme at top level — a side effect that stops tree-shaking. The maintainers switch to preserveModules, move the theme registration into an explicit registerCharts() function, and declare "sideEffects": ["**/*.css"]. A fixture importing only Button drops from 398 kB to 6 kB. Applications that use charts call registerCharts() once, which the changelog documents as the only migration step.
Validation commands
# Inspect the output layout
find dist/esm -name "*.js" | head -20
# No node_modules copies inside dist
find dist -path "*node_modules*" | head -1 || echo "clean"
# Measure what a consumer pays for one import
echo "import { Button } from '@acme/ui'; console.log(Button)" > probe.mjs
npx esbuild probe.mjs --bundle --minify --format=esm --outfile=/tmp/probe.js --metafile=/tmp/meta.json
node -e "const m=require('/tmp/meta.json');for(const [f,i] of Object.entries(m.inputs))if(f.includes('@acme/ui'))console.log(i.bytes,f)"
Prevention and CI/CD guardrails
- Track a tree-shaking budget in CI with a probe import and a size limit (tools such as
size-limitautomate this). - Keep
sideEffectsaccurate: list every file that must run on import, and nothing else. - Avoid top-level registration in library modules; expose explicit setup functions.
- Restrict deep imports through the
exportsmap even when preserving modules.
Frequently Asked Questions
Does preserveModules change my public API?
Not if your exports map lists only the entry points you intend. The extra files are implementation details reachable only through those entries.
Should I also preserve modules for the CommonJS output? It is optional. CommonJS tree-shaking is weaker in every bundler, so the benefit is smaller, but a matching layout keeps the two builds easy to compare.
Why does my preserved output contain a _virtual folder?
Rollup emits helper modules — for example, CommonJS interop helpers from @rollup/plugin-commonjs — as virtual files. They are expected. Configure output.virtualDirname if you want a different folder name.
Will preserved modules make my package slower to install? Marginally. Package managers extract more, smaller files, which costs a little more on Windows and on network filesystems. For a library with a few hundred modules the difference is measured in milliseconds, and pnpm's content-addressed store deduplicates unchanged files across versions.
Can I combine preserveModules with code splitting for lazy-loaded parts?
Yes. Dynamic import() calls in your source remain dynamic imports in the preserved output, so consumers' bundlers can still split those modules into separate chunks for lazy loading.
Related
- Bundling and Build Tooling for Libraries compares build tools for published packages.
- Shrinking Published Package Size with sideEffects and files explains the
sideEffectsdeclaration this technique relies on. - Resolving Rollup 'Unresolved Dependencies' Warnings covers external configuration in Rollup.
- Building a Library with Vite Library Mode applies the same option through Vite.