Rollup Tree-Shaking: How Static Imports Shrink Your Bundle (and What Breaks It)
Rollup's tree-shaking works because ES modules are statically analyzable. Here's how it prunes unused code, a verifiable example, and the side-effect and CommonJS pitfalls that defeat it.
16 Jul 2026, 16:08 UTC

You import one helper from a utility library that ships fifty functions, and your bundle grows by all fifty. That gap between what you imported and what you shipped is exactly the problem Rollup's tree-shaking solves — and understanding why it works (and when it silently doesn't) is the difference between a 4 KB bundle and a 40 KB one.
The thesis is simple: tree-shaking is not a minification trick or a heuristic. It is a consequence of ES modules being statically analyzable. If you keep your code — and your dependencies — statically structured, Rollup can prove what is unused and delete it. Break that static structure, and the dead code stays.
Static structure is the whole trick
With ES modules, import and export statements are fixed at the top of the file. Rollup can read your code without executing it, build a complete dependency graph, and walk outward from your entry point marking every binding that is actually reachable. Anything never marked gets dropped.
Contrast this with CommonJS, where require() is just a function call. It can appear inside an if, receive a computed string, or be aliased and passed around. A bundler cannot know what will be required without running the program, so it conservatively keeps everything. This is why tree-shaking is fundamentally an ES module feature, and why transpilers configured to emit CommonJS (a common Babel or TypeScript misconfiguration) defeat it entirely — set your transpiler to emit ES modules for the bundling step.
Tree-shaking also works at the statement level, not just the module level. If you import one function from a module that defines five, the other four are removed too — provided they have no observable side effects.
A worked example you can verify
Create two files. In math.js:
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
export function divide(a, b) {
if (b === 0) throw new Error('division by zero');
return a / b;
}In index.js:
import { add } from './math.js';
console.log(add(2, 3));Run Rollup from your project root (install it locally with npm install --save-dev rollup, then run npx rollup index.js --file bundle.js --format es). Open bundle.js: you should find add and the console.log, but no trace of multiply or divide. To measure the effect directly, rebuild with tree-shaking disabled — npx rollup index.js --file bundle-all.js --format es --no-treeshake — and compare file sizes. The second bundle keeps all three functions. Don't take my word for the exact output; run it against your installed version and inspect the result.
Side effects: the main reason code survives
Rollup cannot delete code that does something observable when it runs. A top-level console.log, a polyfill that mutates a global, or a call that registers something at import time all force the module to be kept — deleting them would change program behavior.
If you publish a library, declare your intent in package.json:
{
"sideEffects": false
}This tells bundlers every file is safe to prune. If only some files have side effects (say, a CSS import or a polyfill entry), list them instead: "sideEffects": ["./src/polyfill.js"]. As a consumer, you can check a dependency's package.json for this field to predict whether its unused modules will shake out of your bundle.
When a dependency isn't annotated, Rollup's treeshake.moduleSideEffects option gives you explicit control — for example, moduleSideEffects: false in your Rollup config asserts that no module has import-time side effects, or you can pass a function to decide per module. Use this carefully: asserting a module is side-effect-free when it isn't will silently remove behavior, not just bytes.
The limits you have to design around
Tree-shaking is a proof, and proofs fail on dynamic patterns. Code reached through computed property access — obj[name] where name is only known at runtime — cannot be analyzed, so Rollup must keep everything that might be touched. The same applies to eval and to re-export patterns that funnel through runtime logic. The practical rule: keep imports static, named, and explicit.
Mixing CommonJS dependencies into an ES module build is the other big limiter. @rollup/plugin-commonjs can convert many packages, but results vary with how the package is structured — a CommonJS module that builds its exports dynamically may convert into something Rollup still can't prune. When a dependency shakes poorly, check whether it ships an ES module build (often under the module field in its package.json) and make sure your resolver is picking it up.
Finally, option names and defaults can shift across Rollup major versions, so confirm treeshake settings against the documentation for the version you have installed rather than assuming defaults.
Make it a habit, not a hope
The actionable closing: after your next production build, open the output and search for a function you know is unused in your app. If it's there, work backward — check for CommonJS output from your transpiler, missing sideEffects annotations in dependencies, or dynamic access patterns in your own code. Tree-shaking rewards codebases that stay statically explicit, and the bundle size difference is the receipt.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.