Rollup Tree-Shaking: An Architecture Note on Dead-Code Elimination
Tree-shaking is a static-analysis contract, not a minifier toggle. Here is the smallest Rollup design that satisfies it, plus checks and failure modes.
21 Dec 2025, 22:57 UTC

What actually goes wrong
You export ten helpers from a module, import two, and the bundle still contains all ten. Or the opposite: the bundle shrinks, the build passes, and a feature breaks in production because Rollup removed a statement whose side effect it could not see. Both outcomes come from the same mechanism — Rollup's tree-shaking, which is enabled by default and decides what to keep by statically reading ES module import and export statements.
The practical takeaway: tree-shaking is a static-analysis contract, not a minifier setting. It holds when your inputs are ES modules, when side-effect information is accurate, and when nothing downstream re-adds what was removed. The rest of this note covers the smallest design that satisfies that contract and how to check it.
Requirements
- Every file Rollup analyzes must be an ES module. CommonJS dependencies need a conversion plugin such as
@rollup/plugin-commonjsbefore their exports are visible to the analysis. - Side-effect information must be accurate for your own code and for dependencies. Rollup's default is conservative (
moduleSideEffects: true), which keeps more code than necessary but is safe. - A baseline build with
treeshake: false, so a size change can be attributed to tree-shaking rather than to minification or code splitting. - A test suite that runs against the built artifact, not only against source. Source-level tests cannot catch a binding that disappears only after bundling.
Smallest suitable design
Assume src/index.js imports formatDate from src/util.js, which also exports an unused formatCurrency. The minimum viable configuration is an ES-module entry, a resolver, a CommonJS bridge if needed, and an ES-module output.
// rollup.config.js — assumes Rollup 4.x
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.esm.js',
format: 'es', // live bindings preserved; downstream tools can shake further
sourcemap: true,
},
plugins: [
nodeResolve(), // resolve bare specifiers from node_modules
commonjs(), // convert CJS to ESM so analysis can see exports
],
treeshake: 'recommended', // Rollup 4 preset; omit to use the default behaviour
};
Two details matter more than the rest. First, plugin order: anything that rewrites modules must run before Rollup's analysis, or the analysis runs on the wrong text. Second, output format: es keeps live bindings and lets a downstream bundler shake again, while cjs, umd and iife produce a flat bundle in which only this Rollup pass can remove code.
If you use a minifier, place it after tree-shaking. @rollup/plugin-terser is the maintained package for Rollup 3 and 4; the older rollup-plugin-terser is deprecated. Terser performs its own dead-code elimination, so a size drop after adding it is not evidence that Rollup's tree-shaking worked.
Trust and data boundaries
Rollup's analysis boundary is the module text it reads at build time. Everything outside that boundary is invisible to it:
evalandnew Function— Rollup emits a warning when it detectseval, and code reached only through it cannot be traced.- Dynamic
import()with a non-literal specifier — Rollup cannot know which module is meant, so it retains candidates rather than dropping them. - Plugins that inject imports after analysis, such as a Babel transform that adds polyfills. The injected import is real in the output but was never part of the graph Rollup shook.
- Property access through computed keys, for example
handlers[name]. Rollup cannot prove the property is unused, so it keeps it.
Treat dependency side-effect metadata as a boundary too. The sideEffects field in a package's package.json is an assertion by that package's author. Setting moduleSideEffects: 'no-external' tells Rollup to trust that assertion for everything in node_modules; if a package's metadata is wrong, you get a bundle that is smaller and incorrect.
Operational checks
- Build a baseline. Create a second config with
treeshake: falseand a different output path so the two artifacts do not overwrite each other. - Build production. Run from the project root; the process needs write access to the output directory.
- Compare compressed sizes of the two distinct files, not the same file twice.
- Run the test suite against the production bundle.
- Inspect the module graph to confirm a specific unused export is gone, rather than trusting the total size alone.
# from the project root
npx rollup -c rollup.baseline.config.js # output: dist/baseline.esm.js
npx rollup -c rollup.config.js # output: dist/bundle.esm.js
gzip -c dist/baseline.esm.js | wc -c
gzip -c dist/bundle.esm.js | wc -c
For step 5, rollup-plugin-visualizer writes an HTML treemap of the bundle contents. Search it for formatCurrency. If the symbol is absent from the production bundle and present in the baseline, tree-shaking removed it. If it appears in both, the export is being retained — usually because something imports it, because the module is marked as having side effects, or because a plugin re-added it.
Failure modes
| Symptom | Likely cause | First check |
|---|---|---|
| Bundle size unchanged | Input is CommonJS, or the export is reachable | Inspect the graph in the visualizer |
ReferenceError at runtime, only in the built bundle | A removed statement had a side effect Rollup could not detect | Diff baseline against production output for the missing binding |
| Polyfill or CSS import missing | A transform plugin ran after analysis | Reorder plugins so transforms precede analysis |
| Size grows after adding a plugin | The plugin injects imports or disables shaking | Build with and without the plugin |
To mark a call expression as safe to drop, place a /*#__PURE__*/ comment immediately before it. The annotation applies to calls, not to function declarations, and Rollup honours it only while treeshake.annotations is left at its default of true. Annotating a call that does have side effects is a correctness bug you are introducing deliberately.
When the design should change
- Most of your dependency tree is CommonJS with meaningful side effects. Conversion can lose ordering guarantees, and disabling shaking for those packages is safer than converting them.
- You must ship an IIFE for a legacy target and still want post-processing removal. That is a job for the minifier, not for a second Rollup pass.
- Your code relies on patterns Rollup documents as unanalyzable —
eval,with, computed property dispatch. Explicit side-effect annotations or a bundler with different effect tracking may fit better. - You already run a minifier with aggressive dead-code elimination and want a single place to reason about removal. Turning off
treeshakeand relying on the minifier is a legitimate, simpler design.
Limitations
Tree-shaking cannot prove a removed binding is unreachable at runtime; it proves only that no static import path reaches it. Code loaded through a script tag, a dynamic specifier, or a global registry sits outside that proof. The only reliable confirmation is running the built artifact. Keep the baseline config in the repository so the comparison is repeatable, and re-run it whenever plugin order or dependency versions change.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.