Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

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.

Single-file output versus preserved modules Left panel shows one bundled index.js containing every component; right panel shows per-component files with an index that re-exports them. Single file per entry dist/ index.js 412 kB Button, Dialog, Table, DatePicker, Chart, ... import { Button } -> parse 412 kB preserveModules: true dist/ index.js 1 kB (re-exports) Button/Button.js 4 kB Dialog/Dialog.js 9 kB Chart/Chart.js 160 kB import { Button } -> ~5 kB reached
With preserved modules, an import of Button reaches only Button's files; the rest are never parsed.

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: true emits one output file per input module instead of merging them.
  • preserveModulesRoot: 'src' strips the src/ prefix so output paths mirror your source tree under dist/esm/.
  • entryFileNames controls extensions, which matter for Node.js resolution of the CommonJS output.
  • external built from package.json, with a regular expression that also matches subpaths, keeps dependencies out of the output. Without it, preserved modules include copies of node_modules files under dist/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.

Bytes shipped by a consumer importing only Button Example consumer bundle sizes for a component library built as a single file, with preserved modules, and with preserved modules plus sideEffects false. single file, no sideEffects 398 kB preserved modules only 141 kB preserved + sideEffects 6 kB
Preserved modules help, but the big win comes when they are combined with an accurate sideEffects declaration.

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.

A consumer bundler's decision for each re-exported module For each module re-exported by the barrel file, the bundler checks whether its exports are used and whether it may have side effects, deciding to include, analyse or skip it. Is any export from this module used? Include and tree-shake it e.g. Button/Button.js yes Does sideEffects allow skipping it? Skip the file entirely never parsed, zero bytes yes no Load it for side effects top level kept, with its imports no
Unused modules are skipped outright only when the package declares them side-effect-free.

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.preserveModules and preserveModulesRoot, 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: false produces a similar one-file-per-module layout.
  • Plain tsc already emits one file per module. For many libraries without CSS or asset handling needs, compiling with tsc is 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-limit automate this).
  • Keep sideEffects accurate: 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 exports map 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